Merge pull request #54 from yusufipk/fix/bunny-upload-reservation

fix(uploads): count a Bunny upload from the moment it is admitted
This commit is contained in:
Yusuf İpek
2026-08-18 11:12:30 +03:00
committed by GitHub
32 changed files with 1377 additions and 142 deletions
@@ -5,8 +5,9 @@ import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -65,7 +66,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
// The workspace owner comes along because they are the account the
// upload is billed to, and the Bunny reservation released below is held
// against them rather than against whoever is adding the version.
project: { include: { workspace: { select: { ownerId: true } } } },
versions: { orderBy: { versionNumber: 'desc' }, take: 1 },
},
});
@@ -135,6 +139,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
let versionSizeBytes = BigInt(0);
let bunnyReservation: string | null = null;
let persistedProviderVideoId = normalizedProviderVideoId;
let finalizedR2Session: {
sessionId: string;
@@ -148,14 +153,23 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
const grant = readBunnyUploadGrant(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedProviderVideoId,
});
if (!isValidUploadToken) {
if (!grant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
// The size the upload was admitted on, written down here so the account is
// charged for it from this moment. Bunny reports nothing at all until it
// has finished encoding, which for a half-hour video is the better part of
// an hour, and until this row existed those bytes were simply invisible:
// the uploader's own storage page read zero and the next upload was
// measured against a total that ignored the one before it.
versionSizeBytes = grant.declaredSizeBytes ?? BigInt(0);
bunnyReservation = grant.reservationId;
} else if (normalizedProviderId === 'r2') {
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
if (!normalizedObjectKey || !normalizedUploadToken) {
@@ -221,11 +235,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
where: {
id: finalizedR2Session.reservationId,
billedUserId: finalizedR2Session.billedUserId,
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
},
});
}
}
// Handed over in the same transaction that records the size, so the bytes
// are never counted twice and never counted zero times.
if (bunnyReservation) {
await tx.uploadReservation.deleteMany({
where: {
id: bunnyReservation,
billedUserId: video.project.workspace.ownerId,
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
},
});
}
return tx.videoVersion.create({
data: {
versionNumber: nextVersionNumber,
@@ -5,13 +5,25 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { rateLimit } from '@/lib/rate-limit';
import crypto from 'crypto';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { createBunnyUploadToken, readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
import { enforceStorageQuota } from '@/lib/storage-quota';
import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { parseDeclaredUploadSize } from '@/lib/upload-size';
type RouteParams = { params: Promise<{ projectId: string }> };
// Long enough to outlive a slow upload and Bunny's own reporting delay, matching
// what the R2 video path already reserves for. The reservation is what makes
// concurrent uploads visible to each other, so it has to stay until the bytes it
// stands for are counted, not until the upload finishes.
const BUNNY_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
async function getProjectWithEditAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({
where: { id: projectId },
@@ -64,14 +76,42 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Bunny direct uploads are disabled by this host');
}
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
// The size the client says it is about to upload. It is a claim, not proof,
// and the bytes never pass through us to be checked: they go straight to
// Bunny, whose own reporting is what eventually settles the account. What
// the claim buys is the two things asking for zero bytes could not. An
// upload that plainly does not fit is refused before it starts instead of
// halfway through, and the reservation written below makes concurrent
// uploads visible to each other, where previously every request in the same
// two-minute window read the same stale total and every one of them passed.
const declaredSize = parseDeclaredUploadSize(body?.sizeBytes, getMaxVideoUploadBytes());
if ('error' in declaredSize) {
return apiErrors.badRequest(declaredSize.error);
}
const billedUserId = project.workspace.ownerId;
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
if (quotaError) return quotaError;
const reserveResult = await reserveStorageQuota(
billedUserId,
declaredSize.sizeBytes,
UPLOAD_RESERVATION_PURPOSES.BUNNY,
BUNNY_RESERVATION_TTL_MS
);
if ('error' in reserveResult) return reserveResult.error;
const { reservationId } = reserveResult;
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
@@ -87,6 +127,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (!bunnyRes.ok) {
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
logError('Failed to create Bunny Stream video', await bunnyRes.text());
return apiErrors.internalError('Failed to initialize video upload with provider');
}
@@ -94,6 +139,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const bunnyVideo = await bunnyRes.json();
const videoId = bunnyVideo.guid;
if (typeof videoId !== 'string' || videoId.length === 0) {
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
@@ -109,6 +159,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
userId: session.user.id,
projectId,
videoId,
reservationId,
declaredSizeBytes: declaredSize.sizeBytes,
},
3600
);
@@ -155,15 +207,27 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('videoId and uploadToken are required');
}
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
const grant = readBunnyUploadGrant(uploadToken, {
userId: session.user.id,
projectId,
videoId,
});
if (!isValidUploadToken) {
if (!grant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
// Giving the quota back here rather than waiting for the reservation to
// lapse: an abandoned upload that keeps holding gigabytes for two hours is
// most of a trial's whole allowance, and the account has nothing to show for
// it. Safe to do on a caller's say-so only because the reservation id rides
// inside the signed token next to this video id, so releasing it costs the
// caller the video it belongs to.
await releaseStorageReservation(
grant.reservationId,
project.workspace.ownerId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
const response = successResponse({ message: 'Pending upload cleaned up' });
@@ -7,7 +7,7 @@ import { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token';
import { abortMultipartVideoUpload, completeMultipartVideoUpload } from '@/lib/r2';
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import { objectKeyToVideoProxyPath } from '@/lib/video-upload-validation';
import { releaseStorageReservation } from '@/lib/storage-quota';
import { releaseStorageReservation, UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -155,7 +155,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
where: { id: uploadSession.id, status: 'INITIATED' },
data: { status: 'CANCELLED', consumedAt: new Date() },
});
await releaseStorageReservation(uploadSession.reservationId, uploadSession.billedUserId);
await releaseStorageReservation(
uploadSession.reservationId,
uploadSession.billedUserId,
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
);
return apiErrors.internalError('Failed to complete multipart upload');
}
@@ -35,6 +35,7 @@ import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { createR2UploadSession } from '@/lib/r2-upload-session';
@@ -129,6 +130,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const reserveResult = await reserveStorageQuota(
project.workspace.ownerId,
sizeBytes + THUMBNAIL_RESERVE_BYTES,
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
VIDEO_RESERVATION_TTL_MS
);
if ('error' in reserveResult) return reserveResult.error;
@@ -188,7 +190,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
]);
}
} catch (error) {
await releaseStorageReservation(reserveResult.reservationId, project.workspace.ownerId);
await releaseStorageReservation(
reserveResult.reservationId,
project.workspace.ownerId,
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
);
logError('Failed to create presigned video upload URL:', error);
return apiErrors.internalError('Failed to initialize video upload');
}
@@ -340,7 +346,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
logError('Failed to delete pending R2 video object:', error);
}
await releaseStorageReservation(uploadSession.reservationId, uploadSession.billedUserId);
await releaseStorageReservation(
uploadSession.reservationId,
uploadSession.billedUserId,
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
+33 -4
View File
@@ -5,8 +5,9 @@ import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
@@ -77,7 +78,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Check project access (must be owner, project admin, or workspace admin)
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, ownerId: true, workspaceId: true, visibility: true },
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
// The billed account, which is who the Bunny reservation is held against.
workspace: { select: { ownerId: true } },
},
});
if (!project) {
@@ -135,6 +144,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
let versionSizeBytes = BigInt(0);
let bunnyReservation: string | null = null;
let finalizedR2Session: {
sessionId: string;
reservationId: string | null;
@@ -147,14 +157,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
const grant = readBunnyUploadGrant(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedVideoId,
});
if (!isValidUploadToken) {
if (!grant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
// See the versions route: Bunny reports no size at all until it has
// finished encoding, so the size the upload was admitted on is what the
// account is charged until a real figure arrives.
versionSizeBytes = grant.declaredSizeBytes ?? BigInt(0);
bunnyReservation = grant.reservationId;
} else if (normalizedProviderId === 'r2') {
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
if (!normalizedObjectKey || !normalizedUploadToken) {
@@ -222,11 +238,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
where: {
id: finalizedR2Session.reservationId,
billedUserId: finalizedR2Session.billedUserId,
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
},
});
}
}
// Released in the transaction that records the size, so the bytes are never
// counted twice and never counted zero times.
if (bunnyReservation) {
await tx.uploadReservation.deleteMany({
where: {
id: bunnyReservation,
billedUserId: project.workspace.ownerId,
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
},
});
}
return tx.video.create({
data: {
title: title.trim(),
+30 -6
View File
@@ -13,7 +13,11 @@ import {
enforceGuestUploadQuota,
verifyGuestUploadToken,
} from '@/lib/guest-upload-token';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
import {
reserveStorageQuota,
releaseStorageReservation,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { logError } from '@/lib/logger';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -205,7 +209,11 @@ export async function POST(request: NextRequest) {
// All paths use the advisory-locked reservation so concurrent uploads always
// see each other's in-flight sizes, eliminating the TOCTOU race.
const workspaceOwnerId = video.project.workspace.ownerId;
const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size));
const reserveResult = await reserveStorageQuota(
workspaceOwnerId,
BigInt(file.size),
UPLOAD_RESERVATION_PURPOSES.AUDIO
);
if ('error' in reserveResult) return reserveResult.error;
const reservationId = reserveResult.reservationId;
@@ -214,7 +222,11 @@ export async function POST(request: NextRequest) {
const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
const contentType = MIME_ALIASES[strippedType] ?? strippedType;
if (!ALLOWED_TYPES.has(contentType)) {
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
workspaceOwnerId,
UPLOAD_RESERVATION_PURPOSES.AUDIO
);
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
}
@@ -231,12 +243,20 @@ export async function POST(request: NextRequest) {
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
if (isHtmlContent(buffer)) {
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
workspaceOwnerId,
UPLOAD_RESERVATION_PURPOSES.AUDIO
);
return apiErrors.badRequest('File content does not match an audio format');
}
const hasValidMagicBytes = hasValidAudioMagicBytes(buffer.slice(0, 16), contentType);
if (!hasValidMagicBytes) {
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
workspaceOwnerId,
UPLOAD_RESERVATION_PURPOSES.AUDIO
);
return apiErrors.badRequest('File content does not match the declared audio format');
}
@@ -251,7 +271,11 @@ export async function POST(request: NextRequest) {
})
);
} catch (uploadError) {
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
workspaceOwnerId,
UPLOAD_RESERVATION_PURPOSES.AUDIO
);
throw uploadError;
}
+25 -5
View File
@@ -20,7 +20,11 @@ import {
verifyGuestUploadToken,
} from '@/lib/guest-upload-token';
import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
import {
reserveStorageQuota,
releaseStorageReservation,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
@@ -137,14 +141,22 @@ export async function POST(request: NextRequest) {
// All paths use the advisory-locked reservation so concurrent uploads always
// see each other's in-flight sizes, eliminating the TOCTOU race.
const workspaceOwnerId = video.project.workspace.ownerId;
const reserveResult = await reserveStorageQuota(workspaceOwnerId, BigInt(file.size));
const reserveResult = await reserveStorageQuota(
workspaceOwnerId,
BigInt(file.size),
UPLOAD_RESERVATION_PURPOSES.IMAGE
);
if ('error' in reserveResult) return reserveResult.error;
const reservationId = reserveResult.reservationId;
// Check content type
const normalizedMime = normalizeImageMime(file.type);
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
workspaceOwnerId,
UPLOAD_RESERVATION_PURPOSES.IMAGE
);
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
}
@@ -153,7 +165,11 @@ export async function POST(request: NextRequest) {
const buffer = Buffer.from(arrayBuffer);
const detectedMime = detectImageMime(buffer);
if (!detectedMime) {
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
workspaceOwnerId,
UPLOAD_RESERVATION_PURPOSES.IMAGE
);
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
}
@@ -173,7 +189,11 @@ export async function POST(request: NextRequest) {
})
);
} catch (uploadError) {
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
workspaceOwnerId,
UPLOAD_RESERVATION_PURPOSES.IMAGE
);
throw uploadError;
}
+21 -4
View File
@@ -21,7 +21,11 @@ import {
} from '@/lib/video-assets';
import { validateAnnotationStrokes } from '@/lib/validation';
import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
import {
reserveStorageQuota,
releaseStorageReservation,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
type RouteParams = { params: Promise<{ versionId: string }> };
@@ -202,6 +206,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// 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;
@@ -416,10 +423,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (totalAttachmentBytes > BigInt(0)) {
const reserveResult = await reserveStorageQuota(
project.workspace.ownerId,
totalAttachmentBytes
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.
@@ -427,7 +436,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const result = await db.$transaction(async (tx) => {
if (attachmentReservationId) {
await tx.uploadReservation.deleteMany({
where: { id: attachmentReservationId, billedUserId: project.workspace.ownerId },
where: {
id: attachmentReservationId,
billedUserId: project.workspace.ownerId,
purpose: UPLOAD_RESERVATION_PURPOSES.ATTACHMENT,
},
});
}
const comment = await tx.comment.create({
@@ -586,7 +599,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
return withCacheControl(response, 'private, no-store');
} catch (error) {
await releaseStorageReservation(attachmentReservationId);
await releaseStorageReservation(
attachmentReservationId,
attachmentBilledUserId,
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
);
logError('Error creating comment:', error);
return apiErrors.internalError('Failed to create comment');
}
@@ -2,22 +2,51 @@ import crypto from 'crypto';
import { NextRequest } from 'next/server';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import {
createBunnyUploadToken,
readBunnyUploadGrant,
type BunnyUploadGrant,
} from '@/lib/bunny-upload-token';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import {
createGuestUploadToken,
deriveGuestUploadContext,
enforceGuestUploadQuota,
verifyGuestUploadToken,
readGuestUploadGrant,
type GuestUploadGrant,
} from '@/lib/guest-upload-token';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
import { logError } from '@/lib/logger';
import { enforceStorageQuota } from '@/lib/storage-quota';
import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { parseDeclaredUploadSize } from '@/lib/upload-size';
type RouteParams = { params: Promise<{ videoId: string }> };
// Matches the project video path: long enough to outlive a slow upload and
// Bunny's own reporting delay.
const BUNNY_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
/**
* A guest's hold lapses sooner than a member's.
*
* A guest is whoever opened the share link, and the hold is written against the
* workspace owner's quota rather than their own. Declaring a size and then
* walking away costs the guest nothing and costs the owner their whole remaining
* allowance, which on a trial is the entire account. Half an hour is the same
* window the R2 attachment paths already accept, and it bounds what a guest who
* never uploads can take away. A guest whose upload outruns it loses only the
* concurrency guard for the tail of the transfer; the bytes are still recorded
* from the signed size when the asset is created.
*/
const GUEST_BUNNY_RESERVATION_TTL_MS = 30 * 60 * 1000;
// POST /api/videos/[videoId]/assets/bunny-init
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
@@ -37,8 +66,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Direct uploads are disabled by this host');
}
// See the project video route for why the client's declared size is asked for
// and what it is worth: it buys an honest refusal before the upload starts,
// and a reservation that concurrent uploads can see.
const declaredSize = parseDeclaredUploadSize(body?.sizeBytes, getMaxVideoUploadBytes());
if ('error' in declaredSize) {
return apiErrors.badRequest(declaredSize.error);
}
const billedUserId = context.video.project.workspace.ownerId;
const quotaError = await enforceStorageQuota(billedUserId, BigInt(0));
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
if (quotaError) return quotaError;
const shareSession = getShareSessionFromRequest(request, context.video.id);
@@ -52,10 +89,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (quotaError) return quotaError;
}
const reserveResult = await reserveStorageQuota(
billedUserId,
declaredSize.sizeBytes,
UPLOAD_RESERVATION_PURPOSES.BUNNY,
context.viewerUserId ? BUNNY_RESERVATION_TTL_MS : GUEST_BUNNY_RESERVATION_TTL_MS
);
if ('error' in reserveResult) return reserveResult.error;
const { reservationId } = reserveResult;
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
@@ -70,6 +121,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (!bunnyRes.ok) {
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
logError('Failed to create Bunny Stream video asset', await bunnyRes.text());
return apiErrors.internalError('Failed to initialize Bunny upload');
}
@@ -77,6 +133,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const bunnyVideo = await bunnyRes.json();
const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : '';
if (!bunnyVideoId || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
@@ -92,21 +153,36 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: bunnyVideoId,
reservationId,
declaredSizeBytes: declaredSize.sizeBytes,
},
3600
);
} else {
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
if (!expectedContext) {
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.forbidden('Missing trusted client IP header');
}
// The guest grant carries the same three claims the signed-in one does,
// and is bound to the Bunny video as well as to ours. That binding is what
// makes releasing safe on the guest's say-so: presenting this token to
// cancel deletes the upload it stands for, so it cannot be used to drop the
// hold while the transfer carries on.
uploadToken = createGuestUploadToken(
{
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
providerVideoId: bunnyVideoId,
reservationId,
declaredSizeBytes: declaredSize.sizeBytes,
},
3600
);
@@ -144,15 +220,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('videoId and uploadToken are required');
}
// Both grants are read rather than merely checked, because both carry the
// reservation this upload holds. Releasing on the caller's say-so is safe
// only because the id is signed next to this Bunny video id: presenting the
// token costs them the video, which is deleted immediately below.
let grant: BunnyUploadGrant | GuestUploadGrant | null = null;
if (context.viewerUserId) {
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
grant = readBunnyUploadGrant(uploadToken, {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: bunnyVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
} else {
const shareSession = getShareSessionFromRequest(request, context.video.id);
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
@@ -160,17 +239,28 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('Missing trusted client IP header');
}
const isValidUploadToken = verifyGuestUploadToken(uploadToken, {
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
grant = readGuestUploadGrant(
uploadToken,
{
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
},
bunnyVideoId
);
}
if (!grant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
await releaseStorageReservation(
grant.reservationId,
context.video.project.workspace.ownerId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
@@ -26,6 +26,7 @@ import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { createR2UploadSession } from '@/lib/r2-upload-session';
import { getVideoAssetAccessContext } from '@/lib/video-assets';
@@ -97,6 +98,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const reserveResult = await reserveStorageQuota(
billedUserId,
sizeBytes + THUMBNAIL_RESERVE_BYTES,
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
VIDEO_RESERVATION_TTL_MS
);
if ('error' in reserveResult) return reserveResult.error;
@@ -117,7 +119,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
]);
} catch (error) {
await releaseStorageReservation(reserveResult.reservationId, billedUserId);
await releaseStorageReservation(
reserveResult.reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
);
logError('Failed to create presigned asset video upload URL:', error);
return apiErrors.internalError('Failed to initialize video upload');
}
@@ -261,7 +267,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
logError('Failed to delete pending R2 asset video object:', error);
}
await releaseStorageReservation(uploadSession.reservationId, uploadSession.billedUserId);
await releaseStorageReservation(
uploadSession.reservationId,
uploadSession.billedUserId,
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
+81 -26
View File
@@ -6,8 +6,8 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { rateLimit } from '@/lib/rate-limit';
import { db } from '@/lib/db';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-upload-token';
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { deriveGuestUploadContext, readGuestUploadGrant } from '@/lib/guest-upload-token';
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
@@ -32,7 +32,9 @@ import {
enforceStorageQuota,
reserveStorageQuota,
releaseStorageReservation,
PLAN_STORAGE_LIMIT_BYTES,
getStorageLimitForUser,
UPLOAD_RESERVATION_PURPOSES,
type UploadReservationPurpose,
} from '@/lib/storage-quota';
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
@@ -298,6 +300,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// POST /api/videos/[videoId]/assets
export async function POST(request: NextRequest, { params }: RouteParams) {
let reservationId: string | null = null;
// What the reservation above was opened for, and who it is billed to. A hold is
// only ever consumed by the flow that opened it: the id below can arrive in the
// request body, and every hold an account owns is billed to the same user, so
// the id alone would let an image being attached release a video upload that
// was still in flight.
let reservationPurpose: UploadReservationPurpose | null = null;
let reservationBilledUserId: string | null = null;
let finalizedR2AssetSession: {
sessionId: string;
reservationId: string | null;
@@ -342,6 +351,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
let assetSizeBytes = BigInt(0);
const billedUserId = context.video.project.workspace.ownerId;
reservationBilledUserId = billedUserId;
if (provider === VideoAssetProvider.R2_IMAGE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
@@ -359,8 +369,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// the client already supplied a reservationId (new upload flow) the
// existing reservation is consumed in the transaction below. For the
// backward-compat path (no reservationId) we create one here.
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.IMAGE;
if (!reservationId) {
const reserveResult = await reserveStorageQuota(billedUserId, assetSizeBytes);
const reserveResult = await reserveStorageQuota(
billedUserId,
assetSizeBytes,
UPLOAD_RESERVATION_PURPOSES.IMAGE
);
if ('error' in reserveResult) return reserveResult.error;
reservationId = reserveResult.reservationId;
}
@@ -383,8 +398,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
assetSizeBytes = audioCheck.sizeBytes;
// Same reservation logic as R2_IMAGE above
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.AUDIO;
if (!reservationId) {
const reserveResult = await reserveStorageQuota(billedUserId, assetSizeBytes);
const reserveResult = await reserveStorageQuota(
billedUserId,
assetSizeBytes,
UPLOAD_RESERVATION_PURPOSES.AUDIO
);
if ('error' in reserveResult) return reserveResult.error;
reservationId = reserveResult.reservationId;
}
@@ -450,6 +470,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
assetSizeBytes = finalizeResult.sizeBytes;
reservationId = finalizeResult.reservationId;
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.R2_VIDEO;
if (!thumbnailUrl) {
thumbnailUrl = finalizeResult.thumbnailProxyUrl;
}
@@ -494,14 +515,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
if (context.viewerUserId) {
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
const grant = readBunnyUploadGrant(uploadToken, {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: providerVideoId,
});
if (!isValidUploadToken) {
if (!grant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
// Charged from now on the size the upload was admitted on: Bunny reports
// nothing until it has finished encoding, and an asset that reads as zero
// bytes for an hour is an hour of uploads measured against a total that
// does not include it.
assetSizeBytes = grant.declaredSizeBytes ?? BigInt(0);
reservationId = grant.reservationId;
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.BUNNY;
} else {
const shareSession = getShareSessionFromRequest(request, context.video.id);
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
@@ -509,15 +538,28 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('Missing trusted client IP header');
}
const isValidGuestUploadToken = verifyGuestUploadToken(uploadToken, {
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
});
if (!isValidGuestUploadToken) {
// Read rather than merely verified, for the same reason as above: a guest
// upload that reads as zero bytes until Bunny finishes encoding is an hour
// of the owner's quota spent on nothing. The grant is bound to this Bunny
// video, so the size and the hold it names belong to this upload and no
// other.
const guestGrant = readGuestUploadGrant(
uploadToken,
{
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
},
providerVideoId
);
if (!guestGrant) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
assetSizeBytes = guestGrant.declaredSizeBytes ?? BigInt(0);
reservationId = guestGrant.reservationId;
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.BUNNY;
}
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
@@ -529,26 +571,30 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
kind = 'VIDEO';
const quotaError = await enforceStorageQuota(billedUserId, BigInt(0));
const quotaError = await enforceStorageQuota(billedUserId, assetSizeBytes);
if (quotaError) return quotaError;
}
// Pre-fetch Bunny storage BEFORE entering the transaction to avoid making an
// HTTP call while holding a DB connection open (connection-pool exhaustion
// risk under adversarial load). Mirrors the discipline in reserveStorageQuota.
// Only needed for R2 providers where the invalid-reservation fallback quota
// check requires Bunny usage data.
// Needed by every provider that can reach the invalid-reservation fallback
// quota check below, Bunny included. Leaving Bunny out read its own storage as
// zero, and on an account whose storage is all Bunny that made the fallback a
// check that could not fail.
const preFetchedBunnyData =
provider === VideoAssetProvider.R2_IMAGE ||
provider === VideoAssetProvider.R2_AUDIO ||
provider === VideoAssetProvider.R2_VIDEO
? await getCachedUserBunnyStorage()
: null;
provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage();
// The ceiling this account is actually held to, read for the fallback below.
// It used to compare against the plan limit, which is 200 GiB whoever is
// asking: a caller who quoted a reservation id that no longer existed was
// measured against the paid ceiling even on a trial worth 3 GiB.
const storageLimitBytes = await getStorageLimitForUser(billedUserId);
// Create the VideoAsset and atomically consume the upload reservation (if any)
// so the spot is never double-counted.
const created = await db.$transaction(async (tx) => {
if (reservationId) {
if (reservationId && reservationPurpose) {
// Acquire the per-user advisory lock unconditionally so both the happy path
// (valid reservation) and the fallback path (fake/expired reservation ID) are
// serialised — eliminating the TOCTOU race in the deleted.count === 0 branch.
@@ -562,7 +608,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// we fall back to a standard (non-locked) quota check so the bypass attempt
// is caught rather than silently allowed.
const deleted = await tx.uploadReservation.deleteMany({
where: { id: reservationId, billedUserId, expiresAt: { gt: new Date() } },
where: {
id: reservationId,
billedUserId,
purpose: reservationPurpose,
expiresAt: { gt: new Date() },
},
});
if (deleted.count === 0) {
// Reservation didn't exist — enforce quota the normal way inside the tx.
@@ -585,7 +636,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
(r2Row?.total ?? BigInt(0)) +
(resRow?.total ?? BigInt(0)) +
BigInt(bunnyData[billedUserId] ?? 0);
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) {
throw new QuotaExceededInTxError();
}
}
@@ -663,7 +714,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (error instanceof QuotaExceededInTxError) {
return apiErrors.storageExceeded() as NextResponse;
}
await releaseStorageReservation(reservationId);
await releaseStorageReservation(
reservationId,
reservationBilledUserId,
reservationPurpose ?? undefined
);
logError('Error creating video asset:', error);
return apiErrors.internalError('Failed to create asset');
}