diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts index 771efb4..a3ab33c 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts @@ -5,7 +5,7 @@ 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 { logError } from '@/lib/logger'; @@ -65,7 +65,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 +138,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 +152,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) { @@ -226,6 +239,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } } + // 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 }, + }); + } + return tx.videoVersion.create({ data: { versionNumber: nextVersionNumber, diff --git a/app/api/projects/[projectId]/videos/bunny-init/route.ts b/app/api/projects/[projectId]/videos/bunny-init/route.ts index 2eec2c9..895a3b9 100644 --- a/app/api/projects/[projectId]/videos/bunny-init/route.ts +++ b/app/api/projects/[projectId]/videos/bunny-init/route.ts @@ -5,13 +5,28 @@ 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, + verifyBunnyUploadToken, +} 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, +} 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 +79,37 @@ 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, + 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); return apiErrors.internalError('Bunny Stream is not configured correctly'); } @@ -87,6 +125,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!bunnyRes.ok) { + await releaseStorageReservation(reservationId, billedUserId); logError('Failed to create Bunny Stream video', await bunnyRes.text()); return apiErrors.internalError('Failed to initialize video upload with provider'); } @@ -94,6 +133,7 @@ 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); return apiErrors.internalError('Upload provider did not return a valid video identifier'); } @@ -109,6 +149,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { userId: session.user.id, projectId, videoId, + reservationId, + declaredSizeBytes: declaredSize.sizeBytes, }, 3600 ); @@ -164,6 +206,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { 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( + readBunnyUploadGrant(uploadToken, { userId: session.user.id, projectId, videoId }) + ?.reservationId ?? null, + project.workspace.ownerId + ); + await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]); const response = successResponse({ message: 'Pending upload cleaned up' }); diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index cad3a12..5954779 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -5,7 +5,7 @@ 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 { logError } from '@/lib/logger'; import { eventKey, recordEvent } from '@/lib/analytics/record'; @@ -77,7 +77,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 +143,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 +156,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) { @@ -227,6 +242,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } } + // 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 }, + }); + } + return tx.video.create({ data: { title: title.trim(), diff --git a/app/api/videos/[videoId]/assets/bunny-init/route.ts b/app/api/videos/[videoId]/assets/bunny-init/route.ts index 0e25e68..b666e28 100644 --- a/app/api/videos/[videoId]/assets/bunny-init/route.ts +++ b/app/api/videos/[videoId]/assets/bunny-init/route.ts @@ -2,7 +2,11 @@ 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, + verifyBunnyUploadToken, +} from '@/lib/bunny-upload-token'; import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup'; import { createGuestUploadToken, @@ -10,14 +14,23 @@ import { enforceGuestUploadQuota, verifyGuestUploadToken, } 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, +} 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; + // POST /api/videos/[videoId]/assets/bunny-init export async function POST(request: NextRequest, { params }: RouteParams) { try { @@ -37,8 +50,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 +73,19 @@ export async function POST(request: NextRequest, { params }: RouteParams) { if (quotaError) return quotaError; } + const reserveResult = await reserveStorageQuota( + billedUserId, + declaredSize.sizeBytes, + 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); return apiErrors.internalError('Bunny Stream is not configured correctly'); } @@ -70,6 +100,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!bunnyRes.ok) { + await releaseStorageReservation(reservationId, billedUserId); logError('Failed to create Bunny Stream video asset', await bunnyRes.text()); return apiErrors.internalError('Failed to initialize Bunny upload'); } @@ -77,6 +108,7 @@ 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); return apiErrors.internalError('Upload provider did not return a valid video identifier'); } @@ -92,15 +124,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) { userId: context.viewerUserId, projectId: context.video.projectId, videoId: bunnyVideoId, + reservationId, }, 3600 ); } else { const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); if (!expectedContext) { + await releaseStorageReservation(reservationId, billedUserId); 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. + uploadToken = createGuestUploadToken( { projectId: context.video.projectId, @@ -171,6 +212,19 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } } + 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, + projectId: context.video.projectId, + videoId: bunnyVideoId, + })?.reservationId ?? null, + context.video.project.workspace.ownerId + ); + } + await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]); const response = successResponse({ message: 'Pending upload cleaned up' }); return withCacheControl(response, 'private, no-store'); diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts index 0ee35cc..e6b5b44 100644 --- a/app/api/videos/[videoId]/assets/route.ts +++ b/app/api/videos/[videoId]/assets/route.ts @@ -6,7 +6,7 @@ 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 { readBunnyUploadGrant } from '@/lib/bunny-upload-token'; import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-upload-token'; import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; import { getShareSessionFromRequest } from '@/lib/share-session'; @@ -32,7 +32,7 @@ import { enforceStorageQuota, reserveStorageQuota, releaseStorageReservation, - PLAN_STORAGE_LIMIT_BYTES, + getStorageLimitForUser, } from '@/lib/storage-quota'; import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; @@ -494,14 +494,21 @@ 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; } else { const shareSession = getShareSessionFromRequest(request, context.video.id); const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null); @@ -529,7 +536,7 @@ 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; } @@ -545,6 +552,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { ? await getCachedUserBunnyStorage() : null; + // 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) => { @@ -585,7 +598,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(); } } diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx index a2acab3..c2b50c0 100644 --- a/components/video-page/assets-pane.tsx +++ b/components/video-page/assets-pane.tsx @@ -509,7 +509,10 @@ export const AssetsPane = memo(function AssetsPane({ const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, '') }), + body: JSON.stringify({ + title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, ''), + sizeBytes: file.size.toString(), + }), }); const initPayload = (await initRes.json().catch(() => null)) as { data?: { diff --git a/components/video-page/hooks/use-version-actions.ts b/components/video-page/hooks/use-version-actions.ts index ae8de97..f24ee1c 100644 --- a/components/video-page/hooks/use-version-actions.ts +++ b/components/video-page/hooks/use-version-actions.ts @@ -110,7 +110,7 @@ export function useVersionActions({ const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), + body: JSON.stringify({ title, sizeBytes: file.size.toString() }), }); if (!initRes.ok) throw new Error('Failed to initialize upload'); diff --git a/lib/admin-stats.ts b/lib/admin-stats.ts index 897ee51..39955f0 100644 --- a/lib/admin-stats.ts +++ b/lib/admin-stats.ts @@ -233,6 +233,8 @@ export const getCachedUserBunnyStorage = unstable_cache( where: { providerId: 'bunny' }, select: { videoId: true, + // What the uploader declared, used as a floor below. + sizeBytes: true, video: { select: { project: { @@ -255,10 +257,28 @@ export const getCachedUserBunnyStorage = unstable_cache( select: { providerVideoId: true, billedUserId: true, + sizeBytes: true, }, }), ]); + /** + * What this video costs us, as the larger of the two numbers we have. + * + * Bunny reports nothing for a video until it has finished encoding it, + * which on a half-hour source is most of an hour, and reading that zero + * literally meant an upload was free for as long as it was being + * processed: it did not show on the uploader's storage page and it did not + * count against the next upload's quota check. The size declared when the + * upload was admitted stands in until Bunny has a figure of its own, and + * Bunny's wins once it arrives, because the renditions it makes are the + * real bill and they are larger than the source. + */ + const chargeableSize = (reported: number, declared: bigint | null): number => { + const declaredBytes = declared === null ? 0 : Number(declared); + return reported > declaredBytes ? reported : declaredBytes; + }; + const seenVideoIds = new Set(); for (const version of bunnyVersions) { const ownerId = version.video.project.workspace.ownerId; @@ -266,7 +286,7 @@ export const getCachedUserBunnyStorage = unstable_cache( if (seenVideoIds.has(dedupeKey)) continue; seenVideoIds.add(dedupeKey); - const size = bunnyStats.byVideoId[version.videoId] || 0; + const size = chargeableSize(bunnyStats.byVideoId[version.videoId] || 0, version.sizeBytes); perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size; } @@ -277,7 +297,10 @@ export const getCachedUserBunnyStorage = unstable_cache( if (seenVideoIds.has(dedupeKey)) continue; seenVideoIds.add(dedupeKey); - const size = bunnyStats.byVideoId[asset.providerVideoId] || 0; + const size = chargeableSize( + bunnyStats.byVideoId[asset.providerVideoId] || 0, + asset.sizeBytes + ); perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size; } } catch (err) { diff --git a/lib/bunny-upload-token.ts b/lib/bunny-upload-token.ts index d83f54c..14f1273 100644 --- a/lib/bunny-upload-token.ts +++ b/lib/bunny-upload-token.ts @@ -10,6 +10,27 @@ interface BunnyUploadTokenPayload { vid: string; iat: number; exp: number; + /** + * The storage reservation this upload holds, when it holds one. + * + * Carried inside the signature rather than handed to the client as its own + * field, because a reservation id the caller can name is a reservation the + * caller can drop: it would take two inits and one cancel to release the + * quota of an upload that is still running, which is the exact hole the + * reservation exists to close. Signed alongside `vid`, releasing it means + * presenting the token for that video, which also deletes that video. + */ + rid?: string; + /** + * The size the client declared when it asked for this grant, as a decimal + * string because JSON has no integer wide enough. + * + * Signed for the same reason as the reservation: it is written onto the row + * the upload creates and counted as storage until Bunny reports a figure of + * its own, so a client that could restate it at that point would be declaring + * one size to pass the quota check and another to be billed for. + */ + sz?: string; } interface BunnyUploadTokenSubject { @@ -41,12 +62,17 @@ function isValidPayload(value: unknown): value is BunnyUploadTokenPayload { typeof payload.iat === 'number' && Number.isFinite(payload.iat) && typeof payload.exp === 'number' && - Number.isFinite(payload.exp) + Number.isFinite(payload.exp) && + (payload.rid === undefined || typeof payload.rid === 'string') && + (payload.sz === undefined || typeof payload.sz === 'string') ); } export function createBunnyUploadToken( - subject: BunnyUploadTokenSubject, + subject: BunnyUploadTokenSubject & { + reservationId?: string | null; + declaredSizeBytes?: bigint | null; + }, ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS ): string { const now = Math.floor(Date.now() / 1000); @@ -57,6 +83,8 @@ export function createBunnyUploadToken( vid: subject.videoId, iat: now, exp: now + ttlSeconds, + ...(subject.reservationId ? { rid: subject.reservationId } : {}), + ...(subject.declaredSizeBytes ? { sz: subject.declaredSizeBytes.toString() } : {}), }; const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); @@ -64,40 +92,91 @@ export function createBunnyUploadToken( return `${encodedPayload}.${signature}`; } -export function verifyBunnyUploadToken(token: string, subject: BunnyUploadTokenSubject): boolean { +/** + * The verified payload, or null when the token is not a genuine grant for this + * subject. Everything `verifyBunnyUploadToken` promises holds here too; it is + * the same check, returning what it read instead of throwing it away. + */ +function readBunnyUploadToken( + token: string, + subject: BunnyUploadTokenSubject +): BunnyUploadTokenPayload | null { // Resolved before the try. A missing signing secret is a configuration fault, and // swallowing that throw made every upload grant look like a forgery instead. const secret = getBunnyUploadTokenSecret(); try { const parts = token.split('.'); - if (parts.length !== 2) return false; + if (parts.length !== 2) return null; const [encodedPayload, providedSignature] = parts; - if (!encodedPayload || !providedSignature) return false; + if (!encodedPayload || !providedSignature) return null; const expectedSignature = signPayload(encodedPayload, secret); const providedBuffer = Buffer.from(providedSignature, 'utf8'); const expectedBuffer = Buffer.from(expectedSignature, 'utf8'); - if (providedBuffer.length !== expectedBuffer.length) return false; - if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return false; + if (providedBuffer.length !== expectedBuffer.length) return null; + if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return null; const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf8'); const payloadUnknown: unknown = JSON.parse(payloadJson); - if (!isValidPayload(payloadUnknown)) return false; + if (!isValidPayload(payloadUnknown)) return null; const payload = payloadUnknown; const now = Math.floor(Date.now() / 1000); - if (payload.exp < now) return false; + if (payload.exp < now) return null; - return ( - payload.uid === subject.userId && - payload.pid === subject.projectId && - payload.vid === subject.videoId - ); + if ( + payload.uid !== subject.userId || + payload.pid !== subject.projectId || + payload.vid !== subject.videoId + ) { + return null; + } + + return payload; } catch { - return false; + return null; } } + +export function verifyBunnyUploadToken(token: string, subject: BunnyUploadTokenSubject): boolean { + return readBunnyUploadToken(token, subject) !== null; +} + +export interface BunnyUploadGrant { + /** The storage reservation this upload holds, or null if it holds none. */ + reservationId: string | null; + /** What the client said it was uploading, or null on a grant that predates the claim. */ + declaredSizeBytes: bigint | null; +} + +/** + * What a genuine grant for this subject carries, or null when the token is not + * one. + * + * Null and empty fields mean the same thing to every caller: there is nothing + * here to release and nothing to bill, so a grant issued before either claim + * existed keeps working rather than failing an upload in flight. + */ +export function readBunnyUploadGrant( + token: string, + subject: BunnyUploadTokenSubject +): BunnyUploadGrant | null { + const payload = readBunnyUploadToken(token, subject); + if (!payload) return null; + + let declaredSizeBytes: bigint | null = null; + if (payload.sz) { + try { + const parsed = BigInt(payload.sz); + declaredSizeBytes = parsed > BigInt(0) ? parsed : null; + } catch { + declaredSizeBytes = null; + } + } + + return { reservationId: payload.rid ?? null, declaredSizeBytes }; +} diff --git a/lib/client/project-video-upload.ts b/lib/client/project-video-upload.ts index b335081..bc93ba7 100644 --- a/lib/client/project-video-upload.ts +++ b/lib/client/project-video-upload.ts @@ -154,7 +154,9 @@ export async function uploadProjectVideo( const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), + // The server checks this against the quota and holds a reservation for it, + // so an upload that cannot fit is turned away before any of it is sent. + body: JSON.stringify({ title, sizeBytes: file.size.toString() }), }); const initPayload = (await initResponse.json().catch(() => null)) as { diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts index f8e8255..8fca0ac 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -17,7 +17,7 @@ export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024 * directly rather than taking a flag from the caller, so no upload route can * forget to pass it. */ -async function getStorageLimitForUser(userId: string): Promise { +export async function getStorageLimitForUser(userId: string): Promise { const user = await db.user.findUnique({ where: { id: userId }, select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, diff --git a/lib/upload-size.ts b/lib/upload-size.ts new file mode 100644 index 0000000..ab5b4b8 --- /dev/null +++ b/lib/upload-size.ts @@ -0,0 +1,41 @@ +// The size a client declares before a direct upload starts. +// +// Shared because the R2 and Bunny paths have to agree on it: both hand the +// number to the storage quota before a single byte moves, so a value one of them +// would accept and the other would not is a hole in whichever is laxer. + +export type DeclaredUploadSize = { sizeBytes: bigint } | { error: string }; + +/** + * Reads a declared upload size, refusing anything that is not a whole positive + * number of bytes within the host's per-file ceiling. + * + * The number is the client's word and is treated as such. Overstating it only + * spends the caller's own quota, and understating it is caught where the bytes + * land: R2 compares the object against the declaration and deletes it on a + * mismatch, and Bunny's own storage reporting replaces the estimate once the + * upload settles. What is not tolerated is an absent or nonsense value, which is + * what asking for zero bytes effectively was. + */ +export function parseDeclaredUploadSize(raw: unknown, maxBytes: bigint): DeclaredUploadSize { + if (typeof raw !== 'number' && typeof raw !== 'string' && typeof raw !== 'bigint') { + return { error: 'sizeBytes must be a positive integer' }; + } + + let sizeBytes: bigint; + try { + sizeBytes = BigInt(raw); + } catch { + return { error: 'sizeBytes must be a positive integer' }; + } + + if (sizeBytes <= BigInt(0)) { + return { error: 'sizeBytes must be a positive integer' }; + } + + if (sizeBytes > maxBytes) { + return { error: 'File exceeds the maximum allowed upload size' }; + } + + return { sizeBytes }; +} diff --git a/tests/api/bunny-upload-reservation.test.ts b/tests/api/bunny-upload-reservation.test.ts new file mode 100644 index 0000000..f0e988a --- /dev/null +++ b/tests/api/bunny-upload-reservation.test.ts @@ -0,0 +1,179 @@ +// The Bunny init routes used to ask the quota about zero bytes, which meant two +// things at once: an upload that could never fit was only discovered after it had +// been sent, and nothing an upload was about to consume was visible to the next +// request. Bunny reports its own storage on a delay and the figure is cached for +// two minutes on top, so every init inside that window read the same stale total +// and every one of them passed. +// +// These tests pin the two halves of the fix: the declared size is checked before +// a byte moves, and it is held as a reservation the next init has to see. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '@/lib/db'; +import { + DELETE as cancelProjectBunnyUpload, + POST as initProjectBunnyUpload, +} from '@/app/api/projects/[projectId]/videos/bunny-init/route'; +import { apiRequest, callRoute, readData, readError } from '../helpers/request'; +import { signedInAs } from '../helpers/session'; +import { seedProject } from '../factories'; + +const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024); + +/** + * Bunny answers every call the same way, with a fresh video id each time so two + * inits in one test are distinguishable. The cancel path talks to Bunny too, so + * the stub has to cover it rather than just the creation call. + */ +function stubBunnyApi(): void { + let created = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + created += 1; + return new Response(JSON.stringify({ guid: `bunnyvideo-${created}-abcdefgh` }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) + ); +} + +beforeEach(() => { + vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); + vi.stubEnv('OPENFRAME_ENABLE_BUNNY_UPLOADS', 'true'); + vi.stubEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', 'false'); + vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key'); + vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '999999'); + vi.stubEnv('BUNNY_UPLOAD_TOKEN_SECRET', 'test-bunny-upload-token-secret'); + stubBunnyApi(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function initRequest(projectId: string, body: Record) { + return apiRequest(`/api/projects/${projectId}/videos/bunny-init`, { body }); +} + +async function initUpload(projectId: string, sizeBytes: bigint) { + return callRoute( + initProjectBunnyUpload, + initRequest(projectId, { title: 'A clip', sizeBytes: sizeBytes.toString() }), + { projectId } + ); +} + +describe('POST /api/projects/[projectId]/videos/bunny-init', () => { + it('refuses an init that does not say how big the upload is', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const response = await callRoute( + initProjectBunnyUpload, + initRequest(scenario.project.id, { title: 'A clip' }), + { projectId: scenario.project.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toContain('sizeBytes'); + expect(await db.uploadReservation.count()).toBe(0); + }); + + it('refuses a size beyond the host per-file ceiling', async () => { + vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '1024'); + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const response = await initUpload(scenario.project.id, BigInt(2048)); + + expect(response.status).toBe(400); + expect(await readError(response)).toContain('maximum allowed upload size'); + }); + + // The trial ceiling is 3 GiB, so this is refused on the way in rather than + // after four gigabytes have been pushed to Bunny. + it('refuses an upload the remaining quota cannot hold', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const response = await initUpload(scenario.project.id, BigInt(4) * GIB); + + expect(response.status).toBe(507); + expect(await db.uploadReservation.count()).toBe(0); + }); + + it('holds the declared size as a reservation for the workspace owner', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const response = await initUpload(scenario.project.id, BigInt(2) * GIB); + + expect(response.status).toBe(200); + const reservations = await db.uploadReservation.findMany(); + expect(reservations).toHaveLength(1); + expect(reservations[0].billedUserId).toBe(scenario.owner.id); + expect(reservations[0].sizeBytes).toBe(BigInt(2) * GIB); + }); + + // The regression this whole change exists for. Both of these used to be + // granted, because neither could see what the other was about to upload. + it('refuses a second upload that no longer fits beside the first', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const first = await initUpload(scenario.project.id, BigInt(2) * GIB); + const second = await initUpload(scenario.project.id, BigInt(2) * GIB); + + expect(first.status).toBe(200); + expect(second.status).toBe(507); + expect(await db.uploadReservation.count()).toBe(1); + }); +}); + +describe('DELETE /api/projects/[projectId]/videos/bunny-init', () => { + it('gives the quota back when a pending upload is cancelled', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const init = await initUpload(scenario.project.id, BigInt(2) * GIB); + const { videoId, uploadToken } = await readData(init); + + const response = await callRoute( + cancelProjectBunnyUpload, + initRequest(scenario.project.id, { videoId, uploadToken }), + { projectId: scenario.project.id } + ); + + expect(response.status).toBe(200); + expect(await db.uploadReservation.count()).toBe(0); + }); + + // Why the reservation id travels inside the signed token instead of being + // handed to the client as a field of its own: a caller who could name a + // reservation could start two uploads, cancel the cheap one while quoting the + // expensive one's reservation, and keep uploading against quota it no longer + // holds. + it('will not let one upload cancel release another upload reservation', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const first = await initUpload(scenario.project.id, BigInt(1) * GIB); + const second = await initUpload(scenario.project.id, BigInt(1) * GIB); + const firstData = await readData(first); + const secondData = await readData(second); + + const response = await callRoute( + cancelProjectBunnyUpload, + initRequest(scenario.project.id, { + videoId: secondData.videoId, + uploadToken: firstData.uploadToken, + }), + { projectId: scenario.project.id } + ); + + expect(response.status).toBe(403); + expect(await db.uploadReservation.count()).toBe(2); + }); +}); diff --git a/tests/component/hooks/use-version-actions.test.ts b/tests/component/hooks/use-version-actions.test.ts index f8d79e2..db32a36 100644 --- a/tests/component/hooks/use-version-actions.test.ts +++ b/tests/component/hooks/use-version-actions.test.ts @@ -419,7 +419,13 @@ describe('useVersionActions uploading a file to Bunny', () => { await createFromFile(harness); - expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'my clip' }); + // The size goes with the title: the server checks it against the quota and + // reserves it before Bunny is asked for anything, so an upload that cannot + // fit is refused here rather than after it has been sent. + expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ + title: 'my clip', + sizeBytes: '10', + }); expect(tusUploads[0].options.endpoint).toBe('https://video.bunnycdn.com/tusupload'); expect(tusUploads[0].options.headers).toEqual({ AuthorizationSignature: 'sig', @@ -436,7 +442,10 @@ describe('useVersionActions uploading a file to Bunny', () => { await createFromFile(harness); - expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ title: 'Client cut' }); + expect(bodyOf(callsTo(BUNNY_INIT_URL, 'POST')[0])).toEqual({ + title: 'Client cut', + sizeBytes: '10', + }); }); it('registers the version against the Bunny embed and CDN thumbnail', async () => { diff --git a/tests/unit/lib/upload-size.test.ts b/tests/unit/lib/upload-size.test.ts new file mode 100644 index 0000000..0b6dd6f --- /dev/null +++ b/tests/unit/lib/upload-size.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { parseDeclaredUploadSize } from '@/lib/upload-size'; + +const MAX = BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024); + +function size(result: ReturnType): bigint | null { + return 'sizeBytes' in result ? result.sizeBytes : null; +} + +describe('parseDeclaredUploadSize', () => { + it('accepts a size sent as a string, which is how a client sends bytes it cannot hold in a number', () => { + expect(size(parseDeclaredUploadSize('4294967296', MAX))).toBe(BigInt(4294967296)); + }); + + it('accepts a size sent as a number', () => { + expect(size(parseDeclaredUploadSize(1024, MAX))).toBe(BigInt(1024)); + }); + + it('rejects a missing size', () => { + expect(parseDeclaredUploadSize(undefined, MAX)).toEqual({ + error: 'sizeBytes must be a positive integer', + }); + }); + + // Zero was the old behaviour of every Bunny init: it asked the quota whether + // it could store nothing, and the answer was always yes. + it('rejects zero', () => { + expect(parseDeclaredUploadSize(0, MAX)).toEqual({ + error: 'sizeBytes must be a positive integer', + }); + }); + + it('rejects a negative size', () => { + expect(parseDeclaredUploadSize(-1, MAX)).toEqual({ + error: 'sizeBytes must be a positive integer', + }); + }); + + it('rejects a fractional size rather than rounding it', () => { + expect(parseDeclaredUploadSize(1.5, MAX)).toEqual({ + error: 'sizeBytes must be a positive integer', + }); + }); + + it('rejects text that is not a number', () => { + expect(parseDeclaredUploadSize('a lot', MAX)).toEqual({ + error: 'sizeBytes must be a positive integer', + }); + }); + + it('rejects a size over the ceiling', () => { + expect(parseDeclaredUploadSize(MAX + BigInt(1), MAX)).toEqual({ + error: 'File exceeds the maximum allowed upload size', + }); + }); + + it('accepts a size exactly at the ceiling', () => { + expect(size(parseDeclaredUploadSize(MAX, MAX))).toBe(MAX); + }); +});