mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user