mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +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 { rateLimit } from '@/lib/rate-limit';
|
||||||
import { notifyProjectOwner } from '@/lib/notifications';
|
import { notifyProjectOwner } from '@/lib/notifications';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
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 { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||||
|
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
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({
|
const video = await db.video.findFirst({
|
||||||
where: { id: videoId, projectId },
|
where: { id: videoId, projectId },
|
||||||
include: {
|
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 },
|
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() : '';
|
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||||
|
|
||||||
let versionSizeBytes = BigInt(0);
|
let versionSizeBytes = BigInt(0);
|
||||||
|
let bunnyReservation: string | null = null;
|
||||||
let persistedProviderVideoId = normalizedProviderVideoId;
|
let persistedProviderVideoId = normalizedProviderVideoId;
|
||||||
let finalizedR2Session: {
|
let finalizedR2Session: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -148,14 +153,23 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
const grant = readBunnyUploadGrant(normalizedUploadToken, {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
projectId,
|
projectId,
|
||||||
videoId: normalizedProviderVideoId,
|
videoId: normalizedProviderVideoId,
|
||||||
});
|
});
|
||||||
if (!isValidUploadToken) {
|
if (!grant) {
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
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') {
|
} else if (normalizedProviderId === 'r2') {
|
||||||
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
||||||
if (!normalizedObjectKey || !normalizedUploadToken) {
|
if (!normalizedObjectKey || !normalizedUploadToken) {
|
||||||
@@ -221,11 +235,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
where: {
|
where: {
|
||||||
id: finalizedR2Session.reservationId,
|
id: finalizedR2Session.reservationId,
|
||||||
billedUserId: finalizedR2Session.billedUserId,
|
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({
|
return tx.videoVersion.create({
|
||||||
data: {
|
data: {
|
||||||
versionNumber: nextVersionNumber,
|
versionNumber: nextVersionNumber,
|
||||||
|
|||||||
@@ -5,13 +5,25 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
|||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||||
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
import { createBunnyUploadToken, readBunnyUploadGrant } from '@/lib/bunny-upload-token';
|
||||||
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
import { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||||
import { logError } from '@/lib/logger';
|
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 }> };
|
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) {
|
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
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');
|
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;
|
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 apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||||
const libraryId =
|
const libraryId =
|
||||||
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||||
|
|
||||||
if (!apiKey || !libraryId) {
|
if (!apiKey || !libraryId) {
|
||||||
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
billedUserId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||||
|
);
|
||||||
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +127,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!bunnyRes.ok) {
|
if (!bunnyRes.ok) {
|
||||||
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
billedUserId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||||
|
);
|
||||||
logError('Failed to create Bunny Stream video', await bunnyRes.text());
|
logError('Failed to create Bunny Stream video', await bunnyRes.text());
|
||||||
return apiErrors.internalError('Failed to initialize video upload with provider');
|
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 bunnyVideo = await bunnyRes.json();
|
||||||
const videoId = bunnyVideo.guid;
|
const videoId = bunnyVideo.guid;
|
||||||
if (typeof videoId !== 'string' || videoId.length === 0) {
|
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');
|
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,
|
userId: session.user.id,
|
||||||
projectId,
|
projectId,
|
||||||
videoId,
|
videoId,
|
||||||
|
reservationId,
|
||||||
|
declaredSizeBytes: declaredSize.sizeBytes,
|
||||||
},
|
},
|
||||||
3600
|
3600
|
||||||
);
|
);
|
||||||
@@ -155,15 +207,27 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.badRequest('videoId and uploadToken are required');
|
return apiErrors.badRequest('videoId and uploadToken are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
const grant = readBunnyUploadGrant(uploadToken, {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
projectId,
|
projectId,
|
||||||
videoId,
|
videoId,
|
||||||
});
|
});
|
||||||
if (!isValidUploadToken) {
|
if (!grant) {
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
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 }]);
|
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
|
||||||
|
|
||||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
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 { abortMultipartVideoUpload, completeMultipartVideoUpload } from '@/lib/r2';
|
||||||
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||||
import { objectKeyToVideoProxyPath } from '@/lib/video-upload-validation';
|
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';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||||
@@ -155,7 +155,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
where: { id: uploadSession.id, status: 'INITIATED' },
|
where: { id: uploadSession.id, status: 'INITIATED' },
|
||||||
data: { status: 'CANCELLED', consumedAt: new Date() },
|
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');
|
return apiErrors.internalError('Failed to complete multipart upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
enforceStorageQuota,
|
enforceStorageQuota,
|
||||||
releaseStorageReservation,
|
releaseStorageReservation,
|
||||||
reserveStorageQuota,
|
reserveStorageQuota,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES,
|
||||||
} from '@/lib/storage-quota';
|
} from '@/lib/storage-quota';
|
||||||
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const reserveResult = await reserveStorageQuota(
|
const reserveResult = await reserveStorageQuota(
|
||||||
project.workspace.ownerId,
|
project.workspace.ownerId,
|
||||||
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||||
VIDEO_RESERVATION_TTL_MS
|
VIDEO_RESERVATION_TTL_MS
|
||||||
);
|
);
|
||||||
if ('error' in reserveResult) return reserveResult.error;
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
@@ -188,7 +190,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
logError('Failed to create presigned video upload URL:', error);
|
||||||
return apiErrors.internalError('Failed to initialize video upload');
|
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);
|
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' });
|
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
|
|||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { notifyProjectOwner } from '@/lib/notifications';
|
import { notifyProjectOwner } from '@/lib/notifications';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
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 { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||||
|
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
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)
|
// Check project access (must be owner, project admin, or workspace admin)
|
||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
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) {
|
if (!project) {
|
||||||
@@ -135,6 +144,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||||
|
|
||||||
let versionSizeBytes = BigInt(0);
|
let versionSizeBytes = BigInt(0);
|
||||||
|
let bunnyReservation: string | null = null;
|
||||||
let finalizedR2Session: {
|
let finalizedR2Session: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
reservationId: string | null;
|
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');
|
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
|
const grant = readBunnyUploadGrant(normalizedUploadToken, {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
projectId,
|
projectId,
|
||||||
videoId: normalizedVideoId,
|
videoId: normalizedVideoId,
|
||||||
});
|
});
|
||||||
if (!isValidUploadToken) {
|
if (!grant) {
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
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') {
|
} else if (normalizedProviderId === 'r2') {
|
||||||
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
||||||
if (!normalizedObjectKey || !normalizedUploadToken) {
|
if (!normalizedObjectKey || !normalizedUploadToken) {
|
||||||
@@ -222,11 +238,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
where: {
|
where: {
|
||||||
id: finalizedR2Session.reservationId,
|
id: finalizedR2Session.reservationId,
|
||||||
billedUserId: finalizedR2Session.billedUserId,
|
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({
|
return tx.video.create({
|
||||||
data: {
|
data: {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
enforceGuestUploadQuota,
|
enforceGuestUploadQuota,
|
||||||
verifyGuestUploadToken,
|
verifyGuestUploadToken,
|
||||||
} from '@/lib/guest-upload-token';
|
} 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';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
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
|
// All paths use the advisory-locked reservation so concurrent uploads always
|
||||||
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
||||||
const workspaceOwnerId = video.project.workspace.ownerId;
|
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;
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
const reservationId = reserveResult.reservationId;
|
const reservationId = reserveResult.reservationId;
|
||||||
|
|
||||||
@@ -214,7 +222,11 @@ export async function POST(request: NextRequest) {
|
|||||||
const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
|
const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
|
||||||
const contentType = MIME_ALIASES[strippedType] ?? strippedType;
|
const contentType = MIME_ALIASES[strippedType] ?? strippedType;
|
||||||
if (!ALLOWED_TYPES.has(contentType)) {
|
if (!ALLOWED_TYPES.has(contentType)) {
|
||||||
await releaseStorageReservation(reservationId);
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
workspaceOwnerId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||||
|
);
|
||||||
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
|
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
|
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
|
||||||
if (isHtmlContent(buffer)) {
|
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');
|
return apiErrors.badRequest('File content does not match an audio format');
|
||||||
}
|
}
|
||||||
const hasValidMagicBytes = hasValidAudioMagicBytes(buffer.slice(0, 16), contentType);
|
const hasValidMagicBytes = hasValidAudioMagicBytes(buffer.slice(0, 16), contentType);
|
||||||
if (!hasValidMagicBytes) {
|
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');
|
return apiErrors.badRequest('File content does not match the declared audio format');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +271,11 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (uploadError) {
|
} catch (uploadError) {
|
||||||
await releaseStorageReservation(reservationId);
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
workspaceOwnerId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.AUDIO
|
||||||
|
);
|
||||||
throw uploadError;
|
throw uploadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import {
|
|||||||
verifyGuestUploadToken,
|
verifyGuestUploadToken,
|
||||||
} from '@/lib/guest-upload-token';
|
} from '@/lib/guest-upload-token';
|
||||||
import { logError } from '@/lib/logger';
|
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_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 512 * 1024; // file + multipart overhead
|
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
|
// All paths use the advisory-locked reservation so concurrent uploads always
|
||||||
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
// see each other's in-flight sizes, eliminating the TOCTOU race.
|
||||||
const workspaceOwnerId = video.project.workspace.ownerId;
|
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;
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
const reservationId = reserveResult.reservationId;
|
const reservationId = reserveResult.reservationId;
|
||||||
|
|
||||||
// Check content type
|
// Check content type
|
||||||
const normalizedMime = normalizeImageMime(file.type);
|
const normalizedMime = normalizeImageMime(file.type);
|
||||||
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
||||||
await releaseStorageReservation(reservationId);
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
workspaceOwnerId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.IMAGE
|
||||||
|
);
|
||||||
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +165,11 @@ export async function POST(request: NextRequest) {
|
|||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
const detectedMime = detectImageMime(buffer);
|
const detectedMime = detectImageMime(buffer);
|
||||||
if (!detectedMime) {
|
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');
|
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) {
|
} catch (uploadError) {
|
||||||
await releaseStorageReservation(reservationId);
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
workspaceOwnerId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.IMAGE
|
||||||
|
);
|
||||||
throw uploadError;
|
throw uploadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ import {
|
|||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||||
import { logError } from '@/lib/logger';
|
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';
|
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||||
@@ -202,6 +206,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
// POST /api/versions/[versionId]/comments
|
// POST /api/versions/[versionId]/comments
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
let attachmentReservationId: string | null = null;
|
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 {
|
try {
|
||||||
const limited = await rateLimit(request, 'comment');
|
const limited = await rateLimit(request, 'comment');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
@@ -416,10 +423,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (totalAttachmentBytes > BigInt(0)) {
|
if (totalAttachmentBytes > BigInt(0)) {
|
||||||
const reserveResult = await reserveStorageQuota(
|
const reserveResult = await reserveStorageQuota(
|
||||||
project.workspace.ownerId,
|
project.workspace.ownerId,
|
||||||
totalAttachmentBytes
|
totalAttachmentBytes,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||||
);
|
);
|
||||||
if ('error' in reserveResult) return reserveResult.error;
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
attachmentReservationId = reserveResult.reservationId;
|
attachmentReservationId = reserveResult.reservationId;
|
||||||
|
attachmentBilledUserId = project.workspace.ownerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a transaction to create both the comment and any asset rows atomically.
|
// 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) => {
|
const result = await db.$transaction(async (tx) => {
|
||||||
if (attachmentReservationId) {
|
if (attachmentReservationId) {
|
||||||
await tx.uploadReservation.deleteMany({
|
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({
|
const comment = await tx.comment.create({
|
||||||
@@ -586,7 +599,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await releaseStorageReservation(attachmentReservationId);
|
await releaseStorageReservation(
|
||||||
|
attachmentReservationId,
|
||||||
|
attachmentBilledUserId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.ATTACHMENT
|
||||||
|
);
|
||||||
logError('Error creating comment:', error);
|
logError('Error creating comment:', error);
|
||||||
return apiErrors.internalError('Failed to create comment');
|
return apiErrors.internalError('Failed to create comment');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,51 @@ import crypto from 'crypto';
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
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 { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||||
import {
|
import {
|
||||||
createGuestUploadToken,
|
createGuestUploadToken,
|
||||||
deriveGuestUploadContext,
|
deriveGuestUploadContext,
|
||||||
enforceGuestUploadQuota,
|
enforceGuestUploadQuota,
|
||||||
verifyGuestUploadToken,
|
readGuestUploadGrant,
|
||||||
|
type GuestUploadGrant,
|
||||||
} from '@/lib/guest-upload-token';
|
} 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 { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
||||||
import { logError } from '@/lib/logger';
|
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 }> };
|
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
|
// POST /api/videos/[videoId]/assets/bunny-init
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
@@ -37,8 +66,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
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 billedUserId = context.video.project.workspace.ownerId;
|
||||||
const quotaError = await enforceStorageQuota(billedUserId, BigInt(0));
|
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
|
||||||
if (quotaError) return quotaError;
|
if (quotaError) return quotaError;
|
||||||
|
|
||||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||||
@@ -52,10 +89,24 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (quotaError) return quotaError;
|
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 apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||||
const libraryId =
|
const libraryId =
|
||||||
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||||
if (!apiKey || !libraryId) {
|
if (!apiKey || !libraryId) {
|
||||||
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
billedUserId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||||
|
);
|
||||||
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +121,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!bunnyRes.ok) {
|
if (!bunnyRes.ok) {
|
||||||
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
billedUserId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||||
|
);
|
||||||
logError('Failed to create Bunny Stream video asset', await bunnyRes.text());
|
logError('Failed to create Bunny Stream video asset', await bunnyRes.text());
|
||||||
return apiErrors.internalError('Failed to initialize Bunny upload');
|
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 bunnyVideo = await bunnyRes.json();
|
||||||
const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : '';
|
const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : '';
|
||||||
if (!bunnyVideoId || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
|
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');
|
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,
|
userId: context.viewerUserId,
|
||||||
projectId: context.video.projectId,
|
projectId: context.video.projectId,
|
||||||
videoId: bunnyVideoId,
|
videoId: bunnyVideoId,
|
||||||
|
reservationId,
|
||||||
|
declaredSizeBytes: declaredSize.sizeBytes,
|
||||||
},
|
},
|
||||||
3600
|
3600
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||||
if (!expectedContext) {
|
if (!expectedContext) {
|
||||||
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
billedUserId,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
||||||
|
);
|
||||||
return apiErrors.forbidden('Missing trusted client IP header');
|
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(
|
uploadToken = createGuestUploadToken(
|
||||||
{
|
{
|
||||||
projectId: context.video.projectId,
|
projectId: context.video.projectId,
|
||||||
videoId: context.video.id,
|
videoId: context.video.id,
|
||||||
intent: 'bunny',
|
intent: 'bunny',
|
||||||
context: expectedContext,
|
context: expectedContext,
|
||||||
|
providerVideoId: bunnyVideoId,
|
||||||
|
reservationId,
|
||||||
|
declaredSizeBytes: declaredSize.sizeBytes,
|
||||||
},
|
},
|
||||||
3600
|
3600
|
||||||
);
|
);
|
||||||
@@ -144,15 +220,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.badRequest('videoId and uploadToken are required');
|
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) {
|
if (context.viewerUserId) {
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
grant = readBunnyUploadGrant(uploadToken, {
|
||||||
userId: context.viewerUserId,
|
userId: context.viewerUserId,
|
||||||
projectId: context.video.projectId,
|
projectId: context.video.projectId,
|
||||||
videoId: bunnyVideoId,
|
videoId: bunnyVideoId,
|
||||||
});
|
});
|
||||||
if (!isValidUploadToken) {
|
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
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');
|
return apiErrors.forbidden('Missing trusted client IP header');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidUploadToken = verifyGuestUploadToken(uploadToken, {
|
grant = readGuestUploadGrant(
|
||||||
projectId: context.video.projectId,
|
uploadToken,
|
||||||
videoId: context.video.id,
|
{
|
||||||
intent: 'bunny',
|
projectId: context.video.projectId,
|
||||||
context: expectedContext,
|
videoId: context.video.id,
|
||||||
});
|
intent: 'bunny',
|
||||||
if (!isValidUploadToken) {
|
context: expectedContext,
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
},
|
||||||
}
|
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 }]);
|
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]);
|
||||||
const response = successResponse({ message: 'Pending upload cleaned up' });
|
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
enforceStorageQuota,
|
enforceStorageQuota,
|
||||||
releaseStorageReservation,
|
releaseStorageReservation,
|
||||||
reserveStorageQuota,
|
reserveStorageQuota,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES,
|
||||||
} from '@/lib/storage-quota';
|
} from '@/lib/storage-quota';
|
||||||
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||||
import { getVideoAssetAccessContext } from '@/lib/video-assets';
|
import { getVideoAssetAccessContext } from '@/lib/video-assets';
|
||||||
@@ -97,6 +98,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const reserveResult = await reserveStorageQuota(
|
const reserveResult = await reserveStorageQuota(
|
||||||
billedUserId,
|
billedUserId,
|
||||||
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||||
VIDEO_RESERVATION_TTL_MS
|
VIDEO_RESERVATION_TTL_MS
|
||||||
);
|
);
|
||||||
if ('error' in reserveResult) return reserveResult.error;
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
@@ -117,7 +119,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
|
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} 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);
|
logError('Failed to create presigned asset video upload URL:', error);
|
||||||
return apiErrors.internalError('Failed to initialize video upload');
|
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);
|
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' });
|
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||||
return withCacheControl(response, 'private, no-store');
|
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 { rateLimit } from '@/lib/rate-limit';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
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 { deriveGuestUploadContext, readGuestUploadGrant } from '@/lib/guest-upload-token';
|
||||||
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
||||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||||
@@ -32,7 +32,9 @@ import {
|
|||||||
enforceStorageQuota,
|
enforceStorageQuota,
|
||||||
reserveStorageQuota,
|
reserveStorageQuota,
|
||||||
releaseStorageReservation,
|
releaseStorageReservation,
|
||||||
PLAN_STORAGE_LIMIT_BYTES,
|
getStorageLimitForUser,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES,
|
||||||
|
type UploadReservationPurpose,
|
||||||
} from '@/lib/storage-quota';
|
} from '@/lib/storage-quota';
|
||||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
@@ -298,6 +300,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
// POST /api/videos/[videoId]/assets
|
// POST /api/videos/[videoId]/assets
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
let reservationId: string | null = null;
|
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: {
|
let finalizedR2AssetSession: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
reservationId: string | null;
|
reservationId: string | null;
|
||||||
@@ -342,6 +351,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
let assetSizeBytes = BigInt(0);
|
let assetSizeBytes = BigInt(0);
|
||||||
|
|
||||||
const billedUserId = context.video.project.workspace.ownerId;
|
const billedUserId = context.video.project.workspace.ownerId;
|
||||||
|
reservationBilledUserId = billedUserId;
|
||||||
|
|
||||||
if (provider === VideoAssetProvider.R2_IMAGE) {
|
if (provider === VideoAssetProvider.R2_IMAGE) {
|
||||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
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
|
// the client already supplied a reservationId (new upload flow) the
|
||||||
// existing reservation is consumed in the transaction below. For the
|
// existing reservation is consumed in the transaction below. For the
|
||||||
// backward-compat path (no reservationId) we create one here.
|
// backward-compat path (no reservationId) we create one here.
|
||||||
|
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.IMAGE;
|
||||||
if (!reservationId) {
|
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;
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
reservationId = reserveResult.reservationId;
|
reservationId = reserveResult.reservationId;
|
||||||
}
|
}
|
||||||
@@ -383,8 +398,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
assetSizeBytes = audioCheck.sizeBytes;
|
assetSizeBytes = audioCheck.sizeBytes;
|
||||||
|
|
||||||
// Same reservation logic as R2_IMAGE above
|
// Same reservation logic as R2_IMAGE above
|
||||||
|
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.AUDIO;
|
||||||
if (!reservationId) {
|
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;
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
reservationId = reserveResult.reservationId;
|
reservationId = reserveResult.reservationId;
|
||||||
}
|
}
|
||||||
@@ -450,6 +470,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
assetSizeBytes = finalizeResult.sizeBytes;
|
assetSizeBytes = finalizeResult.sizeBytes;
|
||||||
reservationId = finalizeResult.reservationId;
|
reservationId = finalizeResult.reservationId;
|
||||||
|
reservationPurpose = UPLOAD_RESERVATION_PURPOSES.R2_VIDEO;
|
||||||
if (!thumbnailUrl) {
|
if (!thumbnailUrl) {
|
||||||
thumbnailUrl = finalizeResult.thumbnailProxyUrl;
|
thumbnailUrl = finalizeResult.thumbnailProxyUrl;
|
||||||
}
|
}
|
||||||
@@ -494,14 +515,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (context.viewerUserId) {
|
if (context.viewerUserId) {
|
||||||
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
|
const grant = readBunnyUploadGrant(uploadToken, {
|
||||||
userId: context.viewerUserId,
|
userId: context.viewerUserId,
|
||||||
projectId: context.video.projectId,
|
projectId: context.video.projectId,
|
||||||
videoId: providerVideoId,
|
videoId: providerVideoId,
|
||||||
});
|
});
|
||||||
if (!isValidUploadToken) {
|
if (!grant) {
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
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 {
|
} else {
|
||||||
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
||||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
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');
|
return apiErrors.forbidden('Missing trusted client IP header');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidGuestUploadToken = verifyGuestUploadToken(uploadToken, {
|
// Read rather than merely verified, for the same reason as above: a guest
|
||||||
projectId: context.video.projectId,
|
// upload that reads as zero bytes until Bunny finishes encoding is an hour
|
||||||
videoId: context.video.id,
|
// of the owner's quota spent on nothing. The grant is bound to this Bunny
|
||||||
intent: 'bunny',
|
// video, so the size and the hold it names belong to this upload and no
|
||||||
context: expectedContext,
|
// other.
|
||||||
});
|
const guestGrant = readGuestUploadGrant(
|
||||||
if (!isValidGuestUploadToken) {
|
uploadToken,
|
||||||
|
{
|
||||||
|
projectId: context.video.projectId,
|
||||||
|
videoId: context.video.id,
|
||||||
|
intent: 'bunny',
|
||||||
|
context: expectedContext,
|
||||||
|
},
|
||||||
|
providerVideoId
|
||||||
|
);
|
||||||
|
if (!guestGrant) {
|
||||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
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}`);
|
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
|
||||||
@@ -529,26 +571,30 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
kind = 'VIDEO';
|
kind = 'VIDEO';
|
||||||
|
|
||||||
const quotaError = await enforceStorageQuota(billedUserId, BigInt(0));
|
const quotaError = await enforceStorageQuota(billedUserId, assetSizeBytes);
|
||||||
if (quotaError) return quotaError;
|
if (quotaError) return quotaError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fetch Bunny storage BEFORE entering the transaction to avoid making an
|
// Pre-fetch Bunny storage BEFORE entering the transaction to avoid making an
|
||||||
// HTTP call while holding a DB connection open (connection-pool exhaustion
|
// HTTP call while holding a DB connection open (connection-pool exhaustion
|
||||||
// risk under adversarial load). Mirrors the discipline in reserveStorageQuota.
|
// risk under adversarial load). Mirrors the discipline in reserveStorageQuota.
|
||||||
// Only needed for R2 providers where the invalid-reservation fallback quota
|
// Needed by every provider that can reach the invalid-reservation fallback
|
||||||
// check requires Bunny usage data.
|
// 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 =
|
const preFetchedBunnyData =
|
||||||
provider === VideoAssetProvider.R2_IMAGE ||
|
provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage();
|
||||||
provider === VideoAssetProvider.R2_AUDIO ||
|
|
||||||
provider === VideoAssetProvider.R2_VIDEO
|
// The ceiling this account is actually held to, read for the fallback below.
|
||||||
? await getCachedUserBunnyStorage()
|
// It used to compare against the plan limit, which is 200 GiB whoever is
|
||||||
: null;
|
// 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)
|
// Create the VideoAsset and atomically consume the upload reservation (if any)
|
||||||
// so the spot is never double-counted.
|
// so the spot is never double-counted.
|
||||||
const created = await db.$transaction(async (tx) => {
|
const created = await db.$transaction(async (tx) => {
|
||||||
if (reservationId) {
|
if (reservationId && reservationPurpose) {
|
||||||
// Acquire the per-user advisory lock unconditionally so both the happy path
|
// Acquire the per-user advisory lock unconditionally so both the happy path
|
||||||
// (valid reservation) and the fallback path (fake/expired reservation ID) are
|
// (valid reservation) and the fallback path (fake/expired reservation ID) are
|
||||||
// serialised — eliminating the TOCTOU race in the deleted.count === 0 branch.
|
// 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
|
// we fall back to a standard (non-locked) quota check so the bypass attempt
|
||||||
// is caught rather than silently allowed.
|
// is caught rather than silently allowed.
|
||||||
const deleted = await tx.uploadReservation.deleteMany({
|
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) {
|
if (deleted.count === 0) {
|
||||||
// Reservation didn't exist — enforce quota the normal way inside the tx.
|
// 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)) +
|
(r2Row?.total ?? BigInt(0)) +
|
||||||
(resRow?.total ?? BigInt(0)) +
|
(resRow?.total ?? BigInt(0)) +
|
||||||
BigInt(bunnyData[billedUserId] ?? 0);
|
BigInt(bunnyData[billedUserId] ?? 0);
|
||||||
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
|
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) {
|
||||||
throw new QuotaExceededInTxError();
|
throw new QuotaExceededInTxError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -663,7 +714,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (error instanceof QuotaExceededInTxError) {
|
if (error instanceof QuotaExceededInTxError) {
|
||||||
return apiErrors.storageExceeded() as NextResponse;
|
return apiErrors.storageExceeded() as NextResponse;
|
||||||
}
|
}
|
||||||
await releaseStorageReservation(reservationId);
|
await releaseStorageReservation(
|
||||||
|
reservationId,
|
||||||
|
reservationBilledUserId,
|
||||||
|
reservationPurpose ?? undefined
|
||||||
|
);
|
||||||
logError('Error creating video asset:', error);
|
logError('Error creating video asset:', error);
|
||||||
return apiErrors.internalError('Failed to create asset');
|
return apiErrors.internalError('Failed to create asset');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -509,7 +509,10 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 {
|
const initPayload = (await initRes.json().catch(() => null)) as {
|
||||||
data?: {
|
data?: {
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ function getAudioUploadFilename(blob: Blob): string {
|
|||||||
return 'recording.webm';
|
return 'recording.webm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A step of the submit that failed with something worth reading out.
|
||||||
|
*
|
||||||
|
* The attachment goes up before the comment does, so a full account fails on the
|
||||||
|
* image and never reaches the comment at all. Reporting that as "failed to add
|
||||||
|
* comment" tells the uploader to try again, which is the one thing that cannot
|
||||||
|
* work. Carried as its own error type so a network fault, which has no message
|
||||||
|
* anybody wants to see, still falls back to the generic line.
|
||||||
|
*/
|
||||||
|
class CommentSubmitError extends Error {}
|
||||||
|
|
||||||
export function useCommentActions({
|
export function useCommentActions({
|
||||||
videoId,
|
videoId,
|
||||||
setVideo,
|
setVideo,
|
||||||
@@ -262,7 +273,12 @@ export function useCommentActions({
|
|||||||
body: imageFormData,
|
body: imageFormData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!imageRes.ok) throw new Error('Failed to upload image');
|
if (!imageRes.ok) {
|
||||||
|
const imagePayload = (await imageRes.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
throw new CommentSubmitError(imagePayload?.error || 'Failed to upload image');
|
||||||
|
}
|
||||||
const imageDataResponse = await imageRes.json();
|
const imageDataResponse = await imageRes.json();
|
||||||
imageData = { url: imageDataResponse.data.url };
|
imageData = { url: imageDataResponse.data.url };
|
||||||
}
|
}
|
||||||
@@ -320,9 +336,10 @@ export function useCommentActions({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
toast.error('Failed to add comment');
|
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
toast.error(payload?.error || 'Failed to add comment');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
setVideo((prev) => {
|
setVideo((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return {
|
return {
|
||||||
@@ -334,7 +351,7 @@ export function useCommentActions({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
toast.error('Failed to add comment');
|
toast.error(error instanceof CommentSubmitError ? error.message : 'Failed to add comment');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmittingComment(false);
|
setIsSubmittingComment(false);
|
||||||
setIsUploadingImage(false);
|
setIsUploadingImage(false);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function useVersionActions({
|
|||||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||||
|
|||||||
+25
-2
@@ -233,6 +233,8 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
where: { providerId: 'bunny' },
|
where: { providerId: 'bunny' },
|
||||||
select: {
|
select: {
|
||||||
videoId: true,
|
videoId: true,
|
||||||
|
// What the uploader declared, used as a floor below.
|
||||||
|
sizeBytes: true,
|
||||||
video: {
|
video: {
|
||||||
select: {
|
select: {
|
||||||
project: {
|
project: {
|
||||||
@@ -255,10 +257,28 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
select: {
|
select: {
|
||||||
providerVideoId: true,
|
providerVideoId: true,
|
||||||
billedUserId: 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<string>();
|
const seenVideoIds = new Set<string>();
|
||||||
for (const version of bunnyVersions) {
|
for (const version of bunnyVersions) {
|
||||||
const ownerId = version.video.project.workspace.ownerId;
|
const ownerId = version.video.project.workspace.ownerId;
|
||||||
@@ -266,7 +286,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
if (seenVideoIds.has(dedupeKey)) continue;
|
if (seenVideoIds.has(dedupeKey)) continue;
|
||||||
seenVideoIds.add(dedupeKey);
|
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;
|
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +297,10 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
if (seenVideoIds.has(dedupeKey)) continue;
|
if (seenVideoIds.has(dedupeKey)) continue;
|
||||||
seenVideoIds.add(dedupeKey);
|
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;
|
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+94
-15
@@ -10,6 +10,27 @@ interface BunnyUploadTokenPayload {
|
|||||||
vid: string;
|
vid: string;
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: 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 {
|
interface BunnyUploadTokenSubject {
|
||||||
@@ -41,12 +62,17 @@ function isValidPayload(value: unknown): value is BunnyUploadTokenPayload {
|
|||||||
typeof payload.iat === 'number' &&
|
typeof payload.iat === 'number' &&
|
||||||
Number.isFinite(payload.iat) &&
|
Number.isFinite(payload.iat) &&
|
||||||
typeof payload.exp === 'number' &&
|
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(
|
export function createBunnyUploadToken(
|
||||||
subject: BunnyUploadTokenSubject,
|
subject: BunnyUploadTokenSubject & {
|
||||||
|
reservationId?: string | null;
|
||||||
|
declaredSizeBytes?: bigint | null;
|
||||||
|
},
|
||||||
ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS
|
ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS
|
||||||
): string {
|
): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
@@ -57,6 +83,8 @@ export function createBunnyUploadToken(
|
|||||||
vid: subject.videoId,
|
vid: subject.videoId,
|
||||||
iat: now,
|
iat: now,
|
||||||
exp: now + ttlSeconds,
|
exp: now + ttlSeconds,
|
||||||
|
...(subject.reservationId ? { rid: subject.reservationId } : {}),
|
||||||
|
...(subject.declaredSizeBytes ? { sz: subject.declaredSizeBytes.toString() } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||||
@@ -64,40 +92,91 @@ export function createBunnyUploadToken(
|
|||||||
return `${encodedPayload}.${signature}`;
|
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
|
// 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.
|
// swallowing that throw made every upload grant look like a forgery instead.
|
||||||
const secret = getBunnyUploadTokenSecret();
|
const secret = getBunnyUploadTokenSecret();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
if (parts.length !== 2) return false;
|
if (parts.length !== 2) return null;
|
||||||
|
|
||||||
const [encodedPayload, providedSignature] = parts;
|
const [encodedPayload, providedSignature] = parts;
|
||||||
if (!encodedPayload || !providedSignature) return false;
|
if (!encodedPayload || !providedSignature) return null;
|
||||||
|
|
||||||
const expectedSignature = signPayload(encodedPayload, secret);
|
const expectedSignature = signPayload(encodedPayload, secret);
|
||||||
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||||
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
||||||
|
|
||||||
if (providedBuffer.length !== expectedBuffer.length) return false;
|
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||||
if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return false;
|
if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||||
|
|
||||||
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf8');
|
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf8');
|
||||||
const payloadUnknown: unknown = JSON.parse(payloadJson);
|
const payloadUnknown: unknown = JSON.parse(payloadJson);
|
||||||
|
|
||||||
if (!isValidPayload(payloadUnknown)) return false;
|
if (!isValidPayload(payloadUnknown)) return null;
|
||||||
|
|
||||||
const payload = payloadUnknown;
|
const payload = payloadUnknown;
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (payload.exp < now) return false;
|
if (payload.exp < now) return null;
|
||||||
|
|
||||||
return (
|
if (
|
||||||
payload.uid === subject.userId &&
|
payload.uid !== subject.userId ||
|
||||||
payload.pid === subject.projectId &&
|
payload.pid !== subject.projectId ||
|
||||||
payload.vid === subject.videoId
|
payload.vid !== subject.videoId
|
||||||
);
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
} catch {
|
} 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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ export async function uploadProjectVideo(
|
|||||||
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 {
|
const initPayload = (await initResponse.json().catch(() => null)) as {
|
||||||
|
|||||||
+110
-16
@@ -20,6 +20,25 @@ interface GuestUploadTokenPayload {
|
|||||||
exp: number;
|
exp: number;
|
||||||
intent: GuestUploadIntent;
|
intent: GuestUploadIntent;
|
||||||
ctx: string;
|
ctx: string;
|
||||||
|
/**
|
||||||
|
* The provider's own id for the video this grant was issued against.
|
||||||
|
*
|
||||||
|
* `vid` is our video, the one the asset will hang off. That is not enough to
|
||||||
|
* hand a guest the right to release a storage hold: the hold stands for one
|
||||||
|
* particular upload, and a grant that names only our video would let a guest
|
||||||
|
* drop it while the upload it stands for carried on. Binding the provider's id
|
||||||
|
* makes releasing cost the guest the upload itself, which is the same bargain
|
||||||
|
* the signed-in path already makes.
|
||||||
|
*/
|
||||||
|
bvid?: string;
|
||||||
|
/** The storage reservation this upload holds, when it holds one. */
|
||||||
|
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 so a guest cannot
|
||||||
|
* declare one size to pass the quota check and another to be billed for.
|
||||||
|
*/
|
||||||
|
sz?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GuestUploadTokenSubject {
|
interface GuestUploadTokenSubject {
|
||||||
@@ -29,6 +48,20 @@ interface GuestUploadTokenSubject {
|
|||||||
context: string;
|
context: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GuestUploadTokenClaims {
|
||||||
|
/** The provider video id to bind this grant to. */
|
||||||
|
providerVideoId?: string | null;
|
||||||
|
reservationId?: string | null;
|
||||||
|
declaredSizeBytes?: bigint | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestUploadGrant {
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
const TRUSTED_IP_PATTERN = /^[\da-fA-F.:]+$/;
|
const TRUSTED_IP_PATTERN = /^[\da-fA-F.:]+$/;
|
||||||
|
|
||||||
function getGuestUploadTokenSecret(): string {
|
function getGuestUploadTokenSecret(): string {
|
||||||
@@ -77,7 +110,10 @@ function isValidPayload(value: unknown): value is GuestUploadTokenPayload {
|
|||||||
typeof payload.exp === 'number' &&
|
typeof payload.exp === 'number' &&
|
||||||
Number.isFinite(payload.exp) &&
|
Number.isFinite(payload.exp) &&
|
||||||
(payload.intent === 'audio' || payload.intent === 'image' || payload.intent === 'bunny') &&
|
(payload.intent === 'audio' || payload.intent === 'image' || payload.intent === 'bunny') &&
|
||||||
typeof payload.ctx === 'string'
|
typeof payload.ctx === 'string' &&
|
||||||
|
(payload.bvid === undefined || typeof payload.bvid === 'string') &&
|
||||||
|
(payload.rid === undefined || typeof payload.rid === 'string') &&
|
||||||
|
(payload.sz === undefined || typeof payload.sz === 'string')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +131,7 @@ export function deriveGuestUploadContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createGuestUploadToken(
|
export function createGuestUploadToken(
|
||||||
subject: GuestUploadTokenSubject,
|
subject: GuestUploadTokenSubject & GuestUploadTokenClaims,
|
||||||
ttlSeconds = DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS
|
ttlSeconds = DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS
|
||||||
): string {
|
): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
@@ -107,6 +143,9 @@ export function createGuestUploadToken(
|
|||||||
exp: now + ttlSeconds,
|
exp: now + ttlSeconds,
|
||||||
intent: subject.intent,
|
intent: subject.intent,
|
||||||
ctx: subject.context,
|
ctx: subject.context,
|
||||||
|
...(subject.providerVideoId ? { bvid: subject.providerVideoId } : {}),
|
||||||
|
...(subject.reservationId ? { rid: subject.reservationId } : {}),
|
||||||
|
...(subject.declaredSizeBytes ? { sz: subject.declaredSizeBytes.toString() } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||||
@@ -114,39 +153,94 @@ export function createGuestUploadToken(
|
|||||||
return `${encodedPayload}.${signature}`;
|
return `${encodedPayload}.${signature}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyGuestUploadToken(token: string, subject: GuestUploadTokenSubject): boolean {
|
/**
|
||||||
|
* The verified payload, or null when the token is not a genuine grant for this
|
||||||
|
* subject.
|
||||||
|
*
|
||||||
|
* `providerVideoId` is checked only when the grant carries one, so a token
|
||||||
|
* issued before that claim existed keeps working rather than failing an upload
|
||||||
|
* in flight. A grant that does carry one and does not match is a forgery as far
|
||||||
|
* as this is concerned.
|
||||||
|
*/
|
||||||
|
function readGuestUploadToken(
|
||||||
|
token: string,
|
||||||
|
subject: GuestUploadTokenSubject,
|
||||||
|
providerVideoId?: string | null
|
||||||
|
): GuestUploadTokenPayload | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
if (parts.length !== 2) return false;
|
if (parts.length !== 2) return null;
|
||||||
|
|
||||||
const [encodedPayload, providedSignature] = parts;
|
const [encodedPayload, providedSignature] = parts;
|
||||||
if (!encodedPayload || !providedSignature) return false;
|
if (!encodedPayload || !providedSignature) return null;
|
||||||
|
|
||||||
const expectedSignature = signPayload(encodedPayload);
|
const expectedSignature = signPayload(encodedPayload);
|
||||||
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||||
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
||||||
if (providedBuffer.length !== expectedBuffer.length) return false;
|
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||||
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return false;
|
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||||
|
|
||||||
const payloadRaw = Buffer.from(encodedPayload, 'base64url').toString('utf8');
|
const payloadRaw = Buffer.from(encodedPayload, 'base64url').toString('utf8');
|
||||||
const payloadUnknown: unknown = JSON.parse(payloadRaw);
|
const payloadUnknown: unknown = JSON.parse(payloadRaw);
|
||||||
if (!isValidPayload(payloadUnknown)) return false;
|
if (!isValidPayload(payloadUnknown)) return null;
|
||||||
|
|
||||||
const payload = payloadUnknown;
|
const payload = payloadUnknown;
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (payload.exp < now) return false;
|
if (payload.exp < now) return null;
|
||||||
|
|
||||||
return (
|
if (
|
||||||
payload.pid === subject.projectId &&
|
payload.pid !== subject.projectId ||
|
||||||
payload.vid === subject.videoId &&
|
payload.vid !== subject.videoId ||
|
||||||
payload.intent === subject.intent &&
|
payload.intent !== subject.intent ||
|
||||||
payload.ctx === subject.context
|
payload.ctx !== subject.context
|
||||||
);
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.bvid !== undefined && payload.bvid !== providerVideoId) return null;
|
||||||
|
|
||||||
|
return payload;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function verifyGuestUploadToken(
|
||||||
|
token: string,
|
||||||
|
subject: GuestUploadTokenSubject,
|
||||||
|
providerVideoId?: string | null
|
||||||
|
): boolean {
|
||||||
|
return readGuestUploadToken(token, subject, providerVideoId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a genuine guest 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.
|
||||||
|
*/
|
||||||
|
export function readGuestUploadGrant(
|
||||||
|
token: string,
|
||||||
|
subject: GuestUploadTokenSubject,
|
||||||
|
providerVideoId?: string | null
|
||||||
|
): GuestUploadGrant | null {
|
||||||
|
const payload = readGuestUploadToken(token, subject, providerVideoId);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
export async function enforceGuestUploadQuota(
|
export async function enforceGuestUploadQuota(
|
||||||
request: Request,
|
request: Request,
|
||||||
videoId: string,
|
videoId: string,
|
||||||
|
|||||||
+37
-3
@@ -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
|
* directly rather than taking a flag from the caller, so no upload route can
|
||||||
* forget to pass it.
|
* forget to pass it.
|
||||||
*/
|
*/
|
||||||
async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
export async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||||
@@ -29,6 +29,33 @@ async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
|||||||
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
||||||
const RESERVATION_TTL_MS = 30 * 60 * 1000;
|
const RESERVATION_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a hold was opened for.
|
||||||
|
*
|
||||||
|
* A reservation is only ever consumed by the flow that opened it, and the
|
||||||
|
* finalize routes match on this as well as on the id. Without it, naming a
|
||||||
|
* reservation would be enough to drop it: the asset route takes a reservation id
|
||||||
|
* from the request body, and every hold an account owns is billed to the same
|
||||||
|
* user, so an image being attached could quietly release a video upload that was
|
||||||
|
* still in flight. The ids are not secret. Two of them are handed to the client
|
||||||
|
* outright, and the rest ride inside signed-but-readable token payloads.
|
||||||
|
*/
|
||||||
|
export const UPLOAD_RESERVATION_PURPOSES = {
|
||||||
|
/** A comment attachment or standalone image going to R2. */
|
||||||
|
IMAGE: 'IMAGE',
|
||||||
|
/** A voice note going to R2. */
|
||||||
|
AUDIO: 'AUDIO',
|
||||||
|
/** Image and voice attachments weighed together when a comment is posted. */
|
||||||
|
ATTACHMENT: 'ATTACHMENT',
|
||||||
|
/** A presigned direct upload to our own S3-compatible storage. */
|
||||||
|
R2_VIDEO: 'R2_VIDEO',
|
||||||
|
/** A direct upload to Bunny, where the bytes never pass through us. */
|
||||||
|
BUNNY: 'BUNNY',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UploadReservationPurpose =
|
||||||
|
(typeof UPLOAD_RESERVATION_PURPOSES)[keyof typeof UPLOAD_RESERVATION_PURPOSES];
|
||||||
|
|
||||||
// Sentinel error thrown inside a Prisma transaction to signal quota exceeded
|
// Sentinel error thrown inside a Prisma transaction to signal quota exceeded
|
||||||
class QuotaExceededError extends Error {}
|
class QuotaExceededError extends Error {}
|
||||||
|
|
||||||
@@ -137,6 +164,7 @@ export async function enforceStorageQuota(
|
|||||||
export async function reserveStorageQuota(
|
export async function reserveStorageQuota(
|
||||||
userId: string,
|
userId: string,
|
||||||
incomingSizeBytes: bigint,
|
incomingSizeBytes: bigint,
|
||||||
|
purpose: UploadReservationPurpose,
|
||||||
reservationTtlMs: number = RESERVATION_TTL_MS
|
reservationTtlMs: number = RESERVATION_TTL_MS
|
||||||
): Promise<{ reservationId: string | null } | { error: NextResponse }> {
|
): Promise<{ reservationId: string | null } | { error: NextResponse }> {
|
||||||
if (!isStripeFeatureEnabled()) {
|
if (!isStripeFeatureEnabled()) {
|
||||||
@@ -199,7 +227,7 @@ export async function reserveStorageQuota(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reservation = await tx.uploadReservation.create({
|
const reservation = await tx.uploadReservation.create({
|
||||||
data: { billedUserId: userId, sizeBytes: incomingSizeBytes, expiresAt },
|
data: { billedUserId: userId, sizeBytes: incomingSizeBytes, expiresAt, purpose },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,16 +246,22 @@ export async function reserveStorageQuota(
|
|||||||
/**
|
/**
|
||||||
* Deletes an upload reservation created by `reserveStorageQuota`.
|
* Deletes an upload reservation created by `reserveStorageQuota`.
|
||||||
* Safe to call with `null` (no-op) for flows where billing is disabled.
|
* Safe to call with `null` (no-op) for flows where billing is disabled.
|
||||||
|
*
|
||||||
|
* Pass the purpose wherever the caller knows it. A release that names only an id
|
||||||
|
* will delete a hold opened for something else, which is the same hole the
|
||||||
|
* purpose column exists to close.
|
||||||
*/
|
*/
|
||||||
export async function releaseStorageReservation(
|
export async function releaseStorageReservation(
|
||||||
reservationId: string | null,
|
reservationId: string | null,
|
||||||
billedUserId?: string | null
|
billedUserId?: string | null,
|
||||||
|
purpose?: UploadReservationPurpose
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!reservationId) return;
|
if (!reservationId) return;
|
||||||
await db.uploadReservation.deleteMany({
|
await db.uploadReservation.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: reservationId,
|
id: reservationId,
|
||||||
...(billedUserId ? { billedUserId } : {}),
|
...(billedUserId ? { billedUserId } : {}),
|
||||||
|
...(purpose ? { purpose } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- AlterTable: record what each hold was opened for, so a reservation can only be
|
||||||
|
-- consumed by the flow that opened it. Without this a caller who could name a
|
||||||
|
-- reservation id could drop any of their own holds through whichever finalize
|
||||||
|
-- route was cheapest, which defeats the point of holding one at all.
|
||||||
|
--
|
||||||
|
-- Rows that predate this column get 'LEGACY', which matches no finalize route.
|
||||||
|
-- Those uploads fall through to the in-transaction quota check instead, and the
|
||||||
|
-- rows lapse on their own TTL within the hour.
|
||||||
|
ALTER TABLE "upload_reservations" ADD COLUMN "purpose" TEXT NOT NULL DEFAULT 'LEGACY';
|
||||||
|
ALTER TABLE "upload_reservations" ALTER COLUMN "purpose" DROP DEFAULT;
|
||||||
@@ -703,6 +703,10 @@ model UploadReservation {
|
|||||||
sizeBytes BigInt
|
sizeBytes BigInt
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
/// What this hold was opened for. A reservation can only be consumed by the
|
||||||
|
/// flow that opened it, so naming one is not enough to drop it. See
|
||||||
|
/// UPLOAD_RESERVATION_PURPOSES in lib/storage-quota.ts.
|
||||||
|
purpose String
|
||||||
|
|
||||||
@@index([billedUserId, expiresAt])
|
@@index([billedUserId, expiresAt])
|
||||||
@@map("upload_reservations")
|
@@map("upload_reservations")
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// 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 { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
|
||||||
|
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||||
|
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
||||||
|
import { signedInAs } from '../helpers/session';
|
||||||
|
import { createVideo, 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<string, unknown>) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reservation id is not a secret and was never going to be one. The upload
|
||||||
|
// token is `base64url(payload).signature`, so the client can read every claim in
|
||||||
|
// it, and the two R2 upload routes hand their reservation ids to the client
|
||||||
|
// outright. What keeps a hold from being dropped by whoever can name it is that
|
||||||
|
// a reservation records what it was opened for, and every finalize route matches
|
||||||
|
// on that as well as on the id.
|
||||||
|
describe('a Bunny hold cannot be consumed by another flow', () => {
|
||||||
|
/** The claims the client can read out of an upload token without our help. */
|
||||||
|
function claimsOf(uploadToken: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(Buffer.from(uploadToken.split('.')[0], 'base64url').toString('utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('puts the reservation id somewhere the client can read it', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
signedInAs(scenario.owner);
|
||||||
|
|
||||||
|
const init = await initUpload(scenario.project.id, BigInt(1) * GIB);
|
||||||
|
const { uploadToken } = await readData(init);
|
||||||
|
|
||||||
|
const reservation = (await db.uploadReservation.findMany())[0];
|
||||||
|
expect(claimsOf(uploadToken).rid).toBe(reservation.id);
|
||||||
|
expect(reservation.purpose).toBe(UPLOAD_RESERVATION_PURPOSES.BUNNY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The attack the purpose column closes. Creating a YouTube asset costs nothing
|
||||||
|
// and consumes no storage, so quoting a Bunny reservation on one was a way to
|
||||||
|
// hand back the quota of an upload that was still running and then start
|
||||||
|
// another. Repeat and a trial worth three gigabytes uploads as much as it
|
||||||
|
// likes for as long as Bunny takes to report.
|
||||||
|
it('ignores a Bunny reservation id quoted on a YouTube asset create', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
|
signedInAs(scenario.owner);
|
||||||
|
|
||||||
|
const init = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||||
|
const { uploadToken } = await readData(init);
|
||||||
|
const reservationId = claimsOf(uploadToken).rid as string;
|
||||||
|
|
||||||
|
const response = await callRoute(
|
||||||
|
createAsset,
|
||||||
|
apiRequest(`/api/videos/${video.id}/assets`, {
|
||||||
|
body: {
|
||||||
|
provider: 'YOUTUBE',
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
reservationId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ videoId: video.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
const reservations = await db.uploadReservation.findMany();
|
||||||
|
expect(reservations).toHaveLength(1);
|
||||||
|
expect(reservations[0].id).toBe(reservationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// And with the hold still standing, the next init has to see it.
|
||||||
|
it('still refuses the next upload after the quoted release attempt', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
|
signedInAs(scenario.owner);
|
||||||
|
|
||||||
|
const init = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||||
|
const { uploadToken } = await readData(init);
|
||||||
|
|
||||||
|
await callRoute(
|
||||||
|
createAsset,
|
||||||
|
apiRequest(`/api/videos/${video.id}/assets`, {
|
||||||
|
body: {
|
||||||
|
provider: 'YOUTUBE',
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
reservationId: claimsOf(uploadToken).rid,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ videoId: video.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
const second = await initUpload(scenario.project.id, BigInt(2) * GIB);
|
||||||
|
expect(second.status).toBe(507);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||||
import { cancelR2UploadSession, createR2UploadSession } from '@/lib/r2-upload-session';
|
import { cancelR2UploadSession, createR2UploadSession } from '@/lib/r2-upload-session';
|
||||||
import { seedProject } from '../factories';
|
import { seedProject } from '../factories';
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ describe('createR2UploadSession', () => {
|
|||||||
data: {
|
data: {
|
||||||
billedUserId: scenario.owner.id,
|
billedUserId: scenario.owner.id,
|
||||||
sizeBytes: BigInt(4096),
|
sizeBytes: BigInt(4096),
|
||||||
|
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||||
expiresAt: new Date(Date.now() + HOUR_MS),
|
expiresAt: new Date(Date.now() + HOUR_MS),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||||
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
|
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
|
||||||
import { createR2UploadToken } from '@/lib/r2-upload-token';
|
import { createR2UploadToken } from '@/lib/r2-upload-token';
|
||||||
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||||
@@ -515,6 +516,7 @@ describe('finalizeR2VideoUpload success', () => {
|
|||||||
data: {
|
data: {
|
||||||
billedUserId: scenario.owner.id,
|
billedUserId: scenario.owner.id,
|
||||||
sizeBytes: BigInt(4096),
|
sizeBytes: BigInt(4096),
|
||||||
|
purpose: UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { db } from '@/lib/db';
|
|||||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||||
import {
|
import {
|
||||||
PLAN_STORAGE_LIMIT_BYTES,
|
PLAN_STORAGE_LIMIT_BYTES,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES,
|
||||||
enforceStorageQuota,
|
enforceStorageQuota,
|
||||||
getUserStorageInfo,
|
getUserStorageInfo,
|
||||||
getUserTotalStorageBytes,
|
getUserTotalStorageBytes,
|
||||||
@@ -83,7 +84,11 @@ describe('the trial ceiling', () => {
|
|||||||
const user = await createUser();
|
const user = await createUser();
|
||||||
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(2) * GIB });
|
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(2) * GIB });
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(2) * GIB);
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(2) * GIB,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('error' in result).toBe(true);
|
expect('error' in result).toBe(true);
|
||||||
expect((result as { error: Response }).error.status).toBe(507);
|
expect((result as { error: Response }).error.status).toBe(507);
|
||||||
@@ -92,7 +97,11 @@ describe('the trial ceiling', () => {
|
|||||||
it('still lets a trial account upload inside its own ceiling', async () => {
|
it('still lets a trial account upload inside its own ceiling', async () => {
|
||||||
const user = await createUser();
|
const user = await createUser();
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(1) * GIB);
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(1) * GIB,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('reservationId' in result).toBe(true);
|
expect('reservationId' in result).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -277,7 +286,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
it('writes a reservation row billed to the user with the requested size', async () => {
|
it('writes a reservation row billed to the user with the requested size', async () => {
|
||||||
const user = await createSubscribedUser();
|
const user = await createSubscribedUser();
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(4096),
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('reservationId' in result).toBe(true);
|
expect('reservationId' in result).toBe(true);
|
||||||
const reservation = await db.uploadReservation.findFirstOrThrow();
|
const reservation = await db.uploadReservation.findFirstOrThrow();
|
||||||
@@ -291,7 +304,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
const user = await createSubscribedUser();
|
const user = await createSubscribedUser();
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(4096),
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ reservationId: null });
|
expect(result).toEqual({ reservationId: null });
|
||||||
expect(await db.uploadReservation.count()).toBe(0);
|
expect(await db.uploadReservation.count()).toBe(0);
|
||||||
@@ -304,7 +321,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(2048),
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('error' in result).toBe(true);
|
expect('error' in result).toBe(true);
|
||||||
expect('error' in result && result.error.status).toBe(507);
|
expect('error' in result && result.error.status).toBe(507);
|
||||||
@@ -321,7 +342,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(100),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await reserveStorageQuota(scenario.owner.id, BigInt(200));
|
const result = await reserveStorageQuota(
|
||||||
|
scenario.owner.id,
|
||||||
|
BigInt(200),
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('error' in result).toBe(true);
|
expect('error' in result).toBe(true);
|
||||||
expect(await db.uploadReservation.count()).toBe(0);
|
expect(await db.uploadReservation.count()).toBe(0);
|
||||||
@@ -331,7 +356,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
const user = await createSubscribedUser();
|
const user = await createSubscribedUser();
|
||||||
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
|
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(2048),
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('error' in result).toBe(true);
|
expect('error' in result).toBe(true);
|
||||||
expect(await db.uploadReservation.count()).toBe(0);
|
expect(await db.uploadReservation.count()).toBe(0);
|
||||||
@@ -345,7 +374,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
expiresInMs: -60_000,
|
expiresInMs: -60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(2048),
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('reservationId' in result).toBe(true);
|
expect('reservationId' in result).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -358,7 +391,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await reserveStorageQuota(light.id, BigInt(10) * GIB);
|
const result = await reserveStorageQuota(
|
||||||
|
light.id,
|
||||||
|
BigInt(10) * GIB,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
|
|
||||||
expect('reservationId' in result).toBe(true);
|
expect('reservationId' in result).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -375,8 +412,8 @@ describe('reserveStorageQuota', () => {
|
|||||||
expect(headroom(used)).toBe(BigInt(30) * GIB);
|
expect(headroom(used)).toBe(BigInt(30) * GIB);
|
||||||
|
|
||||||
const [first, second] = await Promise.all([
|
const [first, second] = await Promise.all([
|
||||||
reserveStorageQuota(user.id, request),
|
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||||
reserveStorageQuota(user.id, request),
|
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const granted = [first, second].filter((result) => 'reservationId' in result);
|
const granted = [first, second].filter((result) => 'reservationId' in result);
|
||||||
@@ -401,8 +438,8 @@ describe('reserveStorageQuota', () => {
|
|||||||
const request = BigInt(20) * GIB;
|
const request = BigInt(20) * GIB;
|
||||||
|
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
reserveStorageQuota(user.id, request),
|
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||||
reserveStorageQuota(user.id, request),
|
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(results.every((result) => 'reservationId' in result)).toBe(true);
|
expect(results.every((result) => 'reservationId' in result)).toBe(true);
|
||||||
@@ -417,7 +454,9 @@ describe('reserveStorageQuota', () => {
|
|||||||
const request = BigInt(20) * GIB;
|
const request = BigInt(20) * GIB;
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
Array.from({ length: 5 }, () => reserveStorageQuota(user.id, request))
|
Array.from({ length: 5 }, () =>
|
||||||
|
reserveStorageQuota(user.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const granted = results.filter((result) => 'reservationId' in result);
|
const granted = results.filter((result) => 'reservationId' in result);
|
||||||
@@ -438,8 +477,8 @@ describe('reserveStorageQuota', () => {
|
|||||||
const request = BigInt(150) * GIB;
|
const request = BigInt(150) * GIB;
|
||||||
|
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
reserveStorageQuota(first.id, request),
|
reserveStorageQuota(first.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||||
reserveStorageQuota(second.id, request),
|
reserveStorageQuota(second.id, request, UPLOAD_RESERVATION_PURPOSES.R2_VIDEO),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(results.every((result) => 'reservationId' in result)).toBe(true);
|
expect(results.every((result) => 'reservationId' in result)).toBe(true);
|
||||||
@@ -450,7 +489,11 @@ describe('reserveStorageQuota', () => {
|
|||||||
describe('releaseStorageReservation', () => {
|
describe('releaseStorageReservation', () => {
|
||||||
it('deletes the reservation and frees the headroom', async () => {
|
it('deletes the reservation and frees the headroom', async () => {
|
||||||
const user = await createSubscribedUser();
|
const user = await createSubscribedUser();
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(10) * GIB);
|
const result = await reserveStorageQuota(
|
||||||
|
user.id,
|
||||||
|
BigInt(10) * GIB,
|
||||||
|
UPLOAD_RESERVATION_PURPOSES.R2_VIDEO
|
||||||
|
);
|
||||||
const reservationId = 'reservationId' in result ? result.reservationId : null;
|
const reservationId = 'reservationId' in result ? result.reservationId : null;
|
||||||
expect(reservationId).toBeTruthy();
|
expect(reservationId).toBeTruthy();
|
||||||
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(10) * GIB);
|
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(10) * GIB);
|
||||||
@@ -484,6 +527,25 @@ describe('releaseStorageReservation', () => {
|
|||||||
|
|
||||||
expect(await db.uploadReservation.count()).toBe(1);
|
expect(await db.uploadReservation.count()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The same scoping in the other direction. Every hold an account owns is
|
||||||
|
// billed to the same user, so `billedUserId` alone does not separate them: an
|
||||||
|
// image being attached would release a Bunny upload that was still in flight
|
||||||
|
// if it could name it, and the ids are readable by the client.
|
||||||
|
it('refuses to delete a reservation opened for a different flow', async () => {
|
||||||
|
const owner = await createSubscribedUser();
|
||||||
|
const reservation = await createUploadReservation({
|
||||||
|
billedUserId: owner.id,
|
||||||
|
sizeBytes: BigInt(4096),
|
||||||
|
purpose: UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
||||||
|
});
|
||||||
|
|
||||||
|
await releaseStorageReservation(reservation.id, owner.id, UPLOAD_RESERVATION_PURPOSES.IMAGE);
|
||||||
|
expect(await db.uploadReservation.count()).toBe(1);
|
||||||
|
|
||||||
|
await releaseStorageReservation(reservation.id, owner.id, UPLOAD_RESERVATION_PURPOSES.BUNNY);
|
||||||
|
expect(await db.uploadReservation.count()).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/settings/storage', () => {
|
describe('GET /api/settings/storage', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { useState } from 'react';
|
import { useState, type ChangeEvent } from 'react';
|
||||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||||
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
||||||
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
|
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
|
||||||
@@ -293,6 +293,72 @@ describe('useCommentActions adding a comment', () => {
|
|||||||
expect(harness.result.current.actions.isSubmittingComment).toBe(false);
|
expect(harness.result.current.actions.isSubmittingComment).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The attachment goes up before the comment does, so a full account fails on
|
||||||
|
// the image and never reaches the comment at all. Reporting that as a comment
|
||||||
|
// that would not post told the uploader to try again, which is the one thing
|
||||||
|
// that cannot work.
|
||||||
|
it('reads out the storage error the attachment upload came back with', async () => {
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/upload/image') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 507,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
error: 'Storage limit exceeded. Please delete some files to free up space.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(ok({ data: serverComment }));
|
||||||
|
});
|
||||||
|
const harness = renderActions();
|
||||||
|
|
||||||
|
// A one-pixel PNG header is enough: the client only sniffs the magic bytes.
|
||||||
|
const png = new File(
|
||||||
|
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||||
|
'n.png',
|
||||||
|
{
|
||||||
|
type: 'image/png',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await harness.result.current.actions.handleImageSelect({
|
||||||
|
target: { files: [png] },
|
||||||
|
} as unknown as ChangeEvent<HTMLInputElement>);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => harness.result.current.actions.setCommentText('Colour is off'));
|
||||||
|
await act(async () => {
|
||||||
|
await harness.result.current.actions.handleAddComment();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toastError).toHaveBeenCalledWith(
|
||||||
|
'Storage limit exceeded. Please delete some files to free up space.'
|
||||||
|
);
|
||||||
|
expect(commentIds(harness)).toEqual(['c1', 'c2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads out the storage error the comment itself came back with', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 507,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
error: 'Storage limit exceeded. Please delete some files to free up space.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const harness = renderActions();
|
||||||
|
|
||||||
|
act(() => harness.result.current.actions.setCommentText('Colour is off'));
|
||||||
|
await act(async () => {
|
||||||
|
await harness.result.current.actions.handleAddComment();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toastError).toHaveBeenCalledWith(
|
||||||
|
'Storage limit exceeded. Please delete some files to free up space.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rolls the comment back out of the list when the request throws', async () => {
|
it('rolls the comment back out of the list when the request throws', async () => {
|
||||||
fetchMock.mockRejectedValue(new Error('offline'));
|
fetchMock.mockRejectedValue(new Error('offline'));
|
||||||
const harness = renderActions();
|
const harness = renderActions();
|
||||||
|
|||||||
@@ -419,7 +419,13 @@ describe('useVersionActions uploading a file to Bunny', () => {
|
|||||||
|
|
||||||
await createFromFile(harness);
|
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.endpoint).toBe('https://video.bunnycdn.com/tusupload');
|
||||||
expect(tusUploads[0].options.headers).toEqual({
|
expect(tusUploads[0].options.headers).toEqual({
|
||||||
AuthorizationSignature: 'sig',
|
AuthorizationSignature: 'sig',
|
||||||
@@ -436,7 +442,10 @@ describe('useVersionActions uploading a file to Bunny', () => {
|
|||||||
|
|
||||||
await createFromFile(harness);
|
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 () => {
|
it('registers the version against the Bunny embed and CDN thumbnail', async () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type VideoVersion,
|
type VideoVersion,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { UPLOAD_RESERVATION_PURPOSES, type UploadReservationPurpose } from '@/lib/storage-quota';
|
||||||
import { nextSeq } from './seq';
|
import { nextSeq } from './seq';
|
||||||
|
|
||||||
export interface CreateVideoInput {
|
export interface CreateVideoInput {
|
||||||
@@ -105,6 +106,8 @@ export interface CreateUploadReservationInput {
|
|||||||
sizeBytes: bigint;
|
sizeBytes: bigint;
|
||||||
/** Milliseconds from now. Negative values produce an already-expired row. */
|
/** Milliseconds from now. Negative values produce an already-expired row. */
|
||||||
expiresInMs?: number;
|
expiresInMs?: number;
|
||||||
|
/** Which flow the hold belongs to. Only that flow can consume it. */
|
||||||
|
purpose?: UploadReservationPurpose;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUploadReservation(
|
export async function createUploadReservation(
|
||||||
@@ -115,6 +118,7 @@ export async function createUploadReservation(
|
|||||||
billedUserId: input.billedUserId,
|
billedUserId: input.billedUserId,
|
||||||
sizeBytes: input.sizeBytes,
|
sizeBytes: input.sizeBytes,
|
||||||
expiresAt: new Date(Date.now() + (input.expiresInMs ?? 30 * 60 * 1000)),
|
expiresAt: new Date(Date.now() + (input.expiresInMs ?? 30 * 60 * 1000)),
|
||||||
|
purpose: input.purpose ?? UPLOAD_RESERVATION_PURPOSES.R2_VIDEO,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const REVIEWED_MIGRATIONS = [
|
|||||||
'20260614160000_add_project_allow_downloads',
|
'20260614160000_add_project_allow_downloads',
|
||||||
'20260627140000_add_video_upload_multipart_id',
|
'20260627140000_add_video_upload_multipart_id',
|
||||||
'20260801120000_add_acquisition_analytics',
|
'20260801120000_add_acquisition_analytics',
|
||||||
|
'20260818120000_add_upload_reservation_purpose',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// The grant a share-link visitor gets for a direct upload.
|
||||||
|
//
|
||||||
|
// A guest's upload is billed to the workspace owner, not to the guest, so this
|
||||||
|
// token is the only thing tying what they declared and what they hold to the
|
||||||
|
// upload it was issued for. Two claims matter here beyond the existing subject
|
||||||
|
// binding: the provider's own video id, which is what makes releasing the hold
|
||||||
|
// on the guest's say-so safe, and the declared size, which is what the asset is
|
||||||
|
// charged until Bunny reports a figure of its own.
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createGuestUploadToken,
|
||||||
|
readGuestUploadGrant,
|
||||||
|
verifyGuestUploadToken,
|
||||||
|
} from '@/lib/guest-upload-token';
|
||||||
|
|
||||||
|
const SUBJECT = {
|
||||||
|
projectId: 'project-1',
|
||||||
|
videoId: 'video-1',
|
||||||
|
intent: 'bunny' as const,
|
||||||
|
context: '203.0.113.7:public',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUNNY_VIDEO_ID = 'bunnyvideo-1-abcdefgh';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('GUEST_UPLOAD_TOKEN_SECRET', 'test-guest-upload-token-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readGuestUploadGrant', () => {
|
||||||
|
it('carries back the reservation and the declared size it was signed with', () => {
|
||||||
|
const token = createGuestUploadToken({
|
||||||
|
...SUBJECT,
|
||||||
|
providerVideoId: BUNNY_VIDEO_ID,
|
||||||
|
reservationId: 'reservation-1',
|
||||||
|
declaredSizeBytes: BigInt(4096),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readGuestUploadGrant(token, SUBJECT, BUNNY_VIDEO_ID)).toEqual({
|
||||||
|
reservationId: 'reservation-1',
|
||||||
|
declaredSizeBytes: BigInt(4096),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The binding that makes a guest release safe: presenting this token to cancel
|
||||||
|
// deletes the upload it stands for, so it cannot be used to drop the hold of
|
||||||
|
// an upload that is still running.
|
||||||
|
it('refuses a grant presented against a different provider video', () => {
|
||||||
|
const token = createGuestUploadToken({
|
||||||
|
...SUBJECT,
|
||||||
|
providerVideoId: BUNNY_VIDEO_ID,
|
||||||
|
reservationId: 'reservation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readGuestUploadGrant(token, SUBJECT, 'bunnyvideo-2-abcdefgh')).toBeNull();
|
||||||
|
expect(readGuestUploadGrant(token, SUBJECT, null)).toBeNull();
|
||||||
|
expect(verifyGuestUploadToken(token, SUBJECT, 'bunnyvideo-2-abcdefgh')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses a grant for another subject, bound video or not', () => {
|
||||||
|
const token = createGuestUploadToken({
|
||||||
|
...SUBJECT,
|
||||||
|
providerVideoId: BUNNY_VIDEO_ID,
|
||||||
|
reservationId: 'reservation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readGuestUploadGrant(token, { ...SUBJECT, videoId: 'video-2' }, BUNNY_VIDEO_ID)
|
||||||
|
).toBeNull();
|
||||||
|
expect(readGuestUploadGrant(token, { ...SUBJECT, intent: 'image' }, BUNNY_VIDEO_ID)).toBeNull();
|
||||||
|
expect(
|
||||||
|
readGuestUploadGrant(token, { ...SUBJECT, context: '198.51.100.9:public' }, BUNNY_VIDEO_ID)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The image and audio grants carry none of this, and a grant issued before the
|
||||||
|
// claims existed keeps working rather than failing an upload in flight.
|
||||||
|
it('reads a grant with no claims as holding nothing', () => {
|
||||||
|
const token = createGuestUploadToken({ ...SUBJECT, intent: 'image' });
|
||||||
|
const subject = { ...SUBJECT, intent: 'image' as const };
|
||||||
|
|
||||||
|
expect(readGuestUploadGrant(token, subject)).toEqual({
|
||||||
|
reservationId: null,
|
||||||
|
declaredSizeBytes: null,
|
||||||
|
});
|
||||||
|
expect(verifyGuestUploadToken(token, subject, BUNNY_VIDEO_ID)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a forged signature', () => {
|
||||||
|
const token = createGuestUploadToken({
|
||||||
|
...SUBJECT,
|
||||||
|
providerVideoId: BUNNY_VIDEO_ID,
|
||||||
|
reservationId: 'reservation-1',
|
||||||
|
});
|
||||||
|
const [payload] = token.split('.');
|
||||||
|
|
||||||
|
expect(readGuestUploadGrant(`${payload}.forged`, SUBJECT, BUNNY_VIDEO_ID)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a non-positive declared size as nothing declared', () => {
|
||||||
|
const token = createGuestUploadToken({
|
||||||
|
...SUBJECT,
|
||||||
|
providerVideoId: BUNNY_VIDEO_ID,
|
||||||
|
declaredSizeBytes: BigInt(-1),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readGuestUploadGrant(token, SUBJECT, BUNNY_VIDEO_ID)?.declaredSizeBytes).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof parseDeclaredUploadSize>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user