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.
This commit is contained in:
2026-08-18 11:07:18 +03:00
parent 4ff801738c
commit 00f1d430b8
22 changed files with 721 additions and 130 deletions
@@ -7,6 +7,7 @@ import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
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 }> };
@@ -234,6 +235,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
where: {
id: finalizedR2Session.reservationId,
billedUserId: finalizedR2Session.billedUserId,
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
},
});
}
@@ -243,7 +245,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// are never counted twice and never counted zero times.
if (bunnyReservation) {
await tx.uploadReservation.deleteMany({
where: { id: bunnyReservation, billedUserId: video.project.workspace.ownerId },
where: {
id: bunnyReservation,
billedUserId: video.project.workspace.ownerId,
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
},
});
}
@@ -5,17 +5,14 @@ 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,
readBunnyUploadGrant,
verifyBunnyUploadToken,
} from '@/lib/bunny-upload-token';
import { createBunnyUploadToken, readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { parseDeclaredUploadSize } from '@/lib/upload-size';
@@ -99,6 +96,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const reserveResult = await reserveStorageQuota(
billedUserId,
declaredSize.sizeBytes,
UPLOAD_RESERVATION_PURPOSES.BUNNY,
BUNNY_RESERVATION_TTL_MS
);
if ('error' in reserveResult) return reserveResult.error;
@@ -109,7 +107,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
await releaseStorageReservation(reservationId, billedUserId);
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
@@ -125,7 +127,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (!bunnyRes.ok) {
await releaseStorageReservation(reservationId, billedUserId);
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');
}
@@ -133,7 +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);
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
@@ -197,12 +207,12 @@ 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');
}
@@ -213,9 +223,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
// inside the signed token next to this video id, so releasing it costs the
// caller the video it belongs to.
await releaseStorageReservation(
readBunnyUploadGrant(uploadToken, { userId: session.user.id, projectId, videoId })
?.reservationId ?? null,
project.workspace.ownerId
grant.reservationId,
project.workspace.ownerId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
@@ -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');
+7 -1
View File
@@ -7,6 +7,7 @@ import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
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';
@@ -237,6 +238,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
where: {
id: finalizedR2Session.reservationId,
billedUserId: finalizedR2Session.billedUserId,
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
},
});
}
@@ -246,7 +248,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// counted twice and never counted zero times.
if (bunnyReservation) {
await tx.uploadReservation.deleteMany({
where: { id: bunnyReservation, billedUserId: project.workspace.ownerId },
where: {
id: bunnyReservation,
billedUserId: project.workspace.ownerId,
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
},
});
}
+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');
}
@@ -5,14 +5,15 @@ import { rateLimit } from '@/lib/rate-limit';
import {
createBunnyUploadToken,
readBunnyUploadGrant,
verifyBunnyUploadToken,
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 { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { getShareSessionFromRequest } from '@/lib/share-session';
@@ -22,6 +23,7 @@ import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import { parseDeclaredUploadSize } from '@/lib/upload-size';
@@ -31,6 +33,20 @@ type RouteParams = { params: Promise<{ videoId: string }> };
// 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 {
@@ -76,7 +92,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const reserveResult = await reserveStorageQuota(
billedUserId,
declaredSize.sizeBytes,
BUNNY_RESERVATION_TTL_MS
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;
@@ -85,7 +102,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const libraryId =
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
await releaseStorageReservation(reservationId, billedUserId);
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
@@ -100,7 +121,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (!bunnyRes.ok) {
await releaseStorageReservation(reservationId, billedUserId);
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');
}
@@ -108,7 +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);
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
@@ -125,29 +154,35 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
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);
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.BUNNY
);
return apiErrors.forbidden('Missing trusted client IP header');
}
// No reservation id in the guest grant, so a guest cancelling waits out the
// two hours instead of getting the quota back at once. The guest token is
// bound to our own video id and the caller's network context, not to the
// Bunny video being uploaded, so a released-on-request reservation could be
// dropped while the upload it stands for carried on. Guests are capped at
// four of these per quarter hour, which bounds what the wait can cost.
// 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
);
@@ -185,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);
@@ -201,30 +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');
}
}
if (context.viewerUserId) {
// Safe on the caller's say-so because the reservation id is signed into the
// same token as this Bunny video id: releasing it costs them the video.
await releaseStorageReservation(
readBunnyUploadGrant(uploadToken, {
userId: context.viewerUserId,
grant = readGuestUploadGrant(
uploadToken,
{
projectId: context.video.projectId,
videoId: bunnyVideoId,
})?.reservationId ?? null,
context.video.project.workspace.ownerId
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');
+62 -20
View File
@@ -7,7 +7,7 @@ import { rateLimit } from '@/lib/rate-limit';
import { db } from '@/lib/db';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { readBunnyUploadGrant } from '@/lib/bunny-upload-token';
import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-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';
@@ -33,6 +33,8 @@ import {
reserveStorageQuota,
releaseStorageReservation,
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;
}
@@ -509,6 +530,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// 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);
@@ -516,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}`);
@@ -543,14 +578,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// 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
@@ -561,7 +594,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// 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.
@@ -575,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.
@@ -676,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');
}