import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { auth, computeProjectAccess, projectAccessInclude } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; 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 { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie, } from '@/lib/guest-identity'; import { eventKey, recordEvent } from '@/lib/analytics/record'; import { extractImageFileNameFromProxyUrl, extractAudioFileNameFromProxyUrl, sanitizeAssetDisplayName, } from '@/lib/video-assets'; import { validateAnnotationStrokes } from '@/lib/validation'; import { parseCommentImageUrls } from '@/lib/comment-images'; import { isFreshAttachment } from '@/lib/upload-freshness'; import { logError } from '@/lib/logger'; import { reserveStorageQuota, releaseStorageReservation, UPLOAD_RESERVATION_PURPOSES, } from '@/lib/storage-quota'; import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation'; type RouteParams = { params: Promise<{ versionId: string }> }; 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; function normalizeEtag(value: string): string { return value.trim().replace(/^W\//, ''); } // GET /api/versions/[versionId]/comments export async function GET(request: NextRequest, { params }: RouteParams) { try { const session = await auth(); const { versionId } = await params; const userId = session?.user?.id; // Get version with project access data pre-fetched in the same query const version = await db.videoVersion.findUnique({ where: { id: versionId }, include: { video: { include: { project: { include: projectAccessInclude(userId) }, }, }, }, }); if (!version) { return apiErrors.notFound('Version'); } const project = version.video.project; const access = computeProjectAccess(project, userId); const shareSession = getShareSessionFromRequest(request, version.video.id); const shareAccess = shareSession ? await validateShareLinkAccess({ token: shareSession.token, projectId: project.id, videoId: version.video.id, requiredPermission: 'VIEW', passwordVerified: shareSession.passwordVerified, }) : { hasAccess: false, requiresPassword: false }; if (!access.hasAccess && !shareAccess.hasAccess) { return apiErrors.forbidden('Access denied'); } const { searchParams } = new URL(request.url); const includeResolved = searchParams.get('includeResolved') !== 'false'; const limit = Math.min(Math.max(1, parseInt(searchParams.get('limit') ?? '200', 10)), 500); const offset = Math.min(Math.max(0, parseInt(searchParams.get('offset') ?? '0', 10)), 50000); const commentsFilter = { versionId, parentId: null as null, ...(includeResolved ? {} : { isResolved: false }), }; const [commentsRevision, total] = await Promise.all([ db.comment.aggregate({ where: { versionId, ...(includeResolved ? {} : { isResolved: false }) }, _count: { id: true }, _max: { updatedAt: true }, }), db.comment.count({ where: commentsFilter }), ]); const etag = `"comments:${versionId}:${includeResolved ? 1 : 0}:${commentsRevision._count.id}:${commentsRevision._max.updatedAt?.getTime() ?? 0}"`; const ifNoneMatch = request.headers.get('if-none-match'); if (ifNoneMatch) { const matches = ifNoneMatch.split(',').map(normalizeEtag).includes(normalizeEtag(etag)); if (matches) { const notModified = new NextResponse(null, { status: 304 }); notModified.headers.set('ETag', etag); return withCacheControl(notModified, 'private, no-cache'); } } const comments = await db.comment.findMany({ where: commentsFilter, orderBy: { timestamp: 'asc' }, skip: offset, take: limit, select: { id: true, content: true, timestamp: true, timestampEnd: true, createdAt: true, updatedAt: true, isResolved: true, resolvedAt: true, voiceUrl: true, voiceDuration: true, imageUrl: true, images: { select: { id: true, url: true }, orderBy: { position: 'asc' } }, annotationData: true, parentId: true, authorId: true, tagId: true, versionId: true, guestName: true, author: { select: { id: true, name: true, image: true } }, tag: { select: { id: true, name: true, color: true } }, replies: { orderBy: { createdAt: 'asc' }, select: { id: true, content: true, timestamp: true, timestampEnd: true, createdAt: true, updatedAt: true, isResolved: true, resolvedAt: true, voiceUrl: true, voiceDuration: true, imageUrl: true, images: { select: { id: true, url: true }, orderBy: { position: 'asc' } }, annotationData: true, parentId: true, authorId: true, tagId: true, versionId: true, guestName: true, author: { select: { id: true, name: true, image: true } }, tag: { select: { id: true, name: true, color: true } }, }, }, }, }); const response = successResponse({ comments, total, hasMore: offset + comments.length < total, offset, limit, }); response.headers.set('ETag', etag); return withCacheControl(response, 'private, no-cache'); } catch (error) { logError('Error fetching comments:', error); return apiErrors.internalError('Failed to fetch comments'); } } // POST /api/versions/[versionId]/comments export async function POST(request: NextRequest, { params }: RouteParams) { let attachmentReservationId: string | null = null; // Carried out of the try so the catch below can scope the release to the // account the hold was opened against. let attachmentBilledUserId: string | null = null; try { const limited = await rateLimit(request, 'comment'); if (limited) return limited; const session = await auth(); const { versionId } = await params; const userId = session?.user?.id; const version = await db.videoVersion.findUnique({ where: { id: versionId }, include: { video: { include: { project: { include: { ...projectAccessInclude(userId), // workspace.select is already included by projectAccessInclude; // ownerId is present on workspace via projectAccessInclude }, }, }, }, }, }); if (!version) { return apiErrors.notFound('Version'); } const project = version.video.project; const access = computeProjectAccess(project, userId); const shareSession = getShareSessionFromRequest(request, version.video.id); const shareAccess = shareSession ? await validateShareLinkAccess({ token: shareSession.token, projectId: project.id, videoId: version.video.id, requiredPermission: 'COMMENT', passwordVerified: shareSession.passwordVerified, }) : { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, }; // Check if user can comment const canComment = access.hasAccess || shareAccess.canComment; if (!canComment) { return apiErrors.forbidden('Access denied'); } const body = await request.json(); const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, annotationData, } = body; // A comment carries a list of images now; `imageUrl` is still accepted as a // one-element list so an older client keeps working. const imageUrlsResult = parseCommentImageUrls(body); if ('error' in imageUrlsResult) { return apiErrors.badRequest(imageUrlsResult.error); } const attachedImageUrls = imageUrlsResult.urls; const primaryImageUrl = attachedImageUrls[0] ?? null; // Validate required fields if (timestamp === undefined || timestamp === null) { return apiErrors.badRequest('Timestamp is required'); } const maxTimestamp = typeof version.duration === 'number' && Number.isFinite(version.duration) ? version.duration : null; const parseCommentTimestamp = (value: unknown, fieldName: string) => { const parsed = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(parsed) || parsed < 0) { return { error: apiErrors.badRequest(`${fieldName} must be a finite non-negative number`), }; } if (maxTimestamp !== null && parsed > maxTimestamp) { return { error: apiErrors.badRequest(`${fieldName} must be less than or equal to video duration`), }; } return { value: parsed }; }; const parsedTimestampResult = parseCommentTimestamp(timestamp, 'Timestamp'); if ('error' in parsedTimestampResult) { return parsedTimestampResult.error; } const parsedTimestamp = parsedTimestampResult.value; let parsedTimestampEnd: number | null = null; if (timestampEnd !== undefined && timestampEnd !== null) { const parsedTimestampEndResult = parseCommentTimestamp(timestampEnd, 'Timestamp end'); if ('error' in parsedTimestampEndResult) { return parsedTimestampEndResult.error; } parsedTimestampEnd = parsedTimestampEndResult.value; if (parsedTimestampEnd < parsedTimestamp) { return apiErrors.badRequest('Timestamp end must be greater than or equal to timestamp'); } } if (!content && !voiceUrl && attachedImageUrls.length === 0 && !annotationData) { return apiErrors.badRequest( 'Either content, a voice recording, an image attachment, or an annotation is required' ); } // Length limits to prevent DB bloat and DoS on export/notification paths if (content !== undefined && content !== null && String(content).length > 10_000) { return apiErrors.badRequest('Comment content must be 10,000 characters or fewer'); } if (guestName !== undefined && guestName !== null && String(guestName).length > 100) { return apiErrors.badRequest('Guest name must be 100 characters or fewer'); } let normalizedGuestEmail: string | null = null; if (guestEmail !== undefined && guestEmail !== null) { normalizedGuestEmail = normalizeEmail(String(guestEmail)); if (!isValidEmailAddress(normalizedGuestEmail)) { return apiErrors.badRequest('Guest email must be a valid email address'); } } // Validate annotation data structure to prevent prototype pollution and stored XSS. // Reject anything that is not a well-formed array of AnnotationStroke objects. // The HTTP body is already JSON-parsed by Next.js; double-encoded strings are rejected. let serializedAnnotationData: string | null = null; if (annotationData !== undefined && annotationData !== null) { if (!Array.isArray(annotationData)) { return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); } const validStrokes = validateAnnotationStrokes(annotationData); if (validStrokes === null) { return apiErrors.badRequest('annotationData must be an array of valid stroke objects'); } // Re-serialize to canonical JSON — strips any extra properties from the input. serializedAnnotationData = JSON.stringify(validStrokes); } // If replying, verify parent exists in same version if (parentId) { const parent = await db.comment.findFirst({ where: { id: parentId, versionId }, }); if (!parent) { return apiErrors.badRequest('Parent comment not found'); } } // Guest comment validation const isGuest = !session?.user?.id; if (isGuest && shareAccess.hasAccess && !shareAccess.allowGuests) { return apiErrors.forbidden('This share link requires sign in to comment'); } if (isGuest && !guestName) { return apiErrors.badRequest('Guest name is required for guest comments'); } // Verify tag belongs to this project to prevent cross-project tag leakage (IDOR) if (tagId) { const tag = await db.commentTag.findFirst({ where: { id: tagId, projectId: project.id }, }); if (!tag) { return apiErrors.badRequest('Tag not found'); } } if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) { return apiErrors.badRequest('Voice URL must reference an uploaded audio file'); } let voiceSizeBytes = BigInt(0); if (voiceUrl) { const voiceCheck = await isFreshAttachment(voiceUrl, 'audio'); if (!voiceCheck.isFresh) { return apiErrors.badRequest('Voice upload expired. Please upload again.'); } voiceSizeBytes = voiceCheck.sizeBytes; } // The uploads happened in parallel, so check them the same way rather than // paying one R2 round trip per screenshot. const imageChecks = await Promise.all( attachedImageUrls.map(async (url) => ({ url, ...(await isFreshAttachment(url, 'image')) })) ); if (imageChecks.some((check) => !check.isFresh)) { return apiErrors.badRequest('Image upload expired. Please upload again.'); } const imageSizeBytes = imageChecks.reduce((total, check) => total + check.sizeBytes, BigInt(0)); const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; // Enforce per-workspace storage quota for any R2 attachments on this comment. // Uses the advisory-locked reservation path so concurrent comment submissions // see each other's in-flight sizes, eliminating the TOCTOU race. const totalAttachmentBytes = voiceSizeBytes + imageSizeBytes; if (totalAttachmentBytes > BigInt(0)) { const reserveResult = await reserveStorageQuota( project.workspace.ownerId, totalAttachmentBytes, UPLOAD_RESERVATION_PURPOSES.ATTACHMENT ); if ('error' in reserveResult) return reserveResult.error; attachmentReservationId = reserveResult.reservationId; attachmentBilledUserId = project.workspace.ownerId; } // Use a transaction to create both the comment and any asset rows atomically. // Consume the reservation inside the transaction so quota is never double-counted. const result = await db.$transaction(async (tx) => { if (attachmentReservationId) { await tx.uploadReservation.deleteMany({ where: { id: attachmentReservationId, billedUserId: project.workspace.ownerId, purpose: UPLOAD_RESERVATION_PURPOSES.ATTACHMENT, }, }); } const comment = await tx.comment.create({ data: { content: content?.trim() || null, timestamp: parsedTimestamp, timestampEnd: parsedTimestampEnd, parentId: parentId || null, voiceUrl: voiceUrl || null, voiceDuration: voiceDuration || null, imageUrl: primaryImageUrl, images: { create: attachedImageUrls.map((url, index) => ({ url, position: index })), }, annotationData: serializedAnnotationData, authorId: session?.user?.id || null, guestName: isGuest ? guestName : null, guestEmail: isGuest ? normalizedGuestEmail : null, guestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null, tagId: tagId || null, versionId, }, include: { author: { select: { id: true, name: true, image: true } }, tag: { select: { id: true, name: true, color: true } }, images: { select: { id: true, url: true }, orderBy: { position: 'asc' } }, replies: { include: { author: { select: { id: true, name: true, image: true } }, tag: { select: { id: true, name: true, color: true } }, images: { select: { id: true, url: true }, orderBy: { position: 'asc' } }, }, }, }, }); // Every attached image also shows up in the assets pane for (const check of imageChecks) { const fileName = extractImageFileNameFromProxyUrl(check.url); const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image'); const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80); await tx.videoAsset.create({ data: { videoId: version.video.id, kind: 'IMAGE', provider: 'R2_IMAGE', displayName, sourceUrl: check.url, thumbnailUrl: check.url, sizeBytes: check.sizeBytes, uploadedByUserId: session?.user?.id || null, uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null, uploadedByGuestName: isGuest ? safeGuestName : null, billedUserId: project.workspace.ownerId, }, }); } // If a voice recording was attached, also track it in the assets pane if (voiceUrl) { const fileName = extractAudioFileNameFromProxyUrl(voiceUrl); const displayName = sanitizeAssetDisplayName(null, fileName || 'Voice Comment'); const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80); await tx.videoAsset.create({ data: { videoId: version.video.id, kind: 'AUDIO', provider: 'R2_AUDIO', displayName, sourceUrl: voiceUrl, sizeBytes: voiceSizeBytes, uploadedByUserId: session?.user?.id || null, uploadedByGuestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null, uploadedByGuestName: isGuest ? safeGuestName : null, billedUserId: project.workspace.ownerId, }, }); } return comment; }); const comment = result; // Notify project owner (fire-and-forget, skip self-notifications) const commentAuthorName = session?.user?.name || guestName || 'Someone'; const isOwnProject = session?.user?.id === project.ownerId; if (!isOwnProject) { const baseUrl = process.env.NEXTAUTH_URL || ''; const videoTitle = version.video.title || 'Untitled Video'; const mins = Math.floor(parseFloat(timestamp) / 60); const secs = Math.floor(parseFloat(timestamp) % 60); const ts = `${mins}:${secs.toString().padStart(2, '0')}`; if (parentId) { // It's a reply — look up parent author const parentComment = await db.comment.findUnique({ where: { id: parentId }, include: { author: { select: { name: true } } }, }); notifyProjectOwner(project.ownerId, { type: 'new_reply', projectName: project.name, videoTitle, replyAuthor: commentAuthorName, replyText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'), parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone', timestamp: ts, url: `${baseUrl}/watch/${version.video.id}`, }).catch((err) => logError('Notification failed:', err)); } else { notifyProjectOwner(project.ownerId, { type: 'new_comment', projectName: project.name, videoTitle, commentAuthor: commentAuthorName, commentText: content?.trim() || (primaryImageUrl ? '(image attachment)' : '(voice note)'), timestamp: ts, url: `${baseUrl}/watch/${version.video.id}`, }).catch((err) => logError('Notification failed:', err)); } } // Feedback arriving from outside the team is the moment this product // becomes worth paying for, so it is the activation step of the funnel. // Keyed on the account, not the comment: what matters is the first time an // account ever received one. if (isGuest) { await recordEvent({ name: 'FIRST_GUEST_COMMENT', dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId), userId: project.workspace.ownerId, }); } 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 commentData = Object.fromEntries( Object.entries(comment).filter(([key]) => key !== 'guestIdentityId') ); 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) { await releaseStorageReservation( attachmentReservationId, attachmentBilledUserId, UPLOAD_RESERVATION_PURPOSES.ATTACHMENT ); logError('Error creating comment:', error); return apiErrors.internalError('Failed to create comment'); } }