Files
OpenFrame/app/api/versions/[versionId]/comments/route.ts
T
yusufipek 00f1d430b8 fix(uploads): stop a storage hold from being dropped by whoever can name it
A reservation id was never a secret and could not have been one. An upload
token is base64url(payload) followed by its signature, so a client can read
every claim out of its own token, and the two R2 init routes hand their
reservation ids to the client outright. The asset route takes a reservation
id from the request body and deleted it on the strength of that id and the
billed user alone, and every hold an account owns is billed to the same user.

So a caller could start a Bunny upload, read the id out of the token they
were just given, quote it while attaching a one byte image or even a bare
YouTube link, and have the quota handed back while the upload carried on.
Repeat and a trial worth three gigabytes uploads as much as it likes for as
long as Bunny takes to report a figure of its own. Signing the id rather than
handing it over bought nothing, because signing is not hiding.

A hold now records what it was opened for and is only ever consumed by that
flow, so naming one is no longer enough to drop it.

Guests hold against the workspace owner's quota rather than their own and had
no way to give it back: the release was gated on being signed in. Declaring a
size and walking away cost the guest nothing and cost the owner their whole
remaining allowance for two hours. The guest grant now carries the reservation
and the declared size, bound to the Bunny video as well as to ours, so
cancelling gives the quota back and costs them the upload it stood for. What a
guest can hold without cancelling lapses in half an hour rather than two hours.

The in-transaction fallback check counted the account's Bunny storage as zero
on a Bunny upload, because the figure was only prefetched for R2 providers and
that branch was unreachable for Bunny until this PR made it reachable. On an
account whose storage is all Bunny that was a check that could not fail. It is
prefetched for every provider that can reach the fallback now.
2026-08-18 11:07:18 +03:00

611 lines
22 KiB
TypeScript

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 { HeadObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
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 { 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_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;
type AttachmentCheck = { isFresh: boolean; sizeBytes: bigint };
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<AttachmentCheck> {
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/';
if (!url.startsWith(prefix)) return { isFresh: false, sizeBytes: BigInt(0) };
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 { isFresh: false, sizeBytes: BigInt(0) };
const isFresh = Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
return { isFresh, sizeBytes: BigInt(head.ContentLength ?? 0) };
} catch {
return { isFresh: false, sizeBytes: BigInt(0) };
}
}
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,
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,
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,
imageUrl,
annotationData,
} = body;
// 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 && !imageUrl && !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;
}
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
return apiErrors.badRequest('Image URL must reference an uploaded image file');
}
let imageSizeBytes = BigInt(0);
if (imageUrl) {
const imageCheck = await isFreshAttachment(imageUrl, 'image');
if (!imageCheck.isFresh) {
return apiErrors.badRequest('Image upload expired. Please upload again.');
}
imageSizeBytes = imageCheck.sizeBytes;
}
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: imageUrl || null,
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 } },
replies: {
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
},
});
// If an image was attached to the comment, also add it to the assets pane
if (imageUrl) {
const fileName = extractImageFileNameFromProxyUrl(imageUrl);
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: imageUrl,
thumbnailUrl: imageUrl,
sizeBytes: imageSizeBytes,
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() || (imageUrl ? '(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() || (imageUrl ? '(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');
}
}