mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
A reservation id was never a secret and could not have been one. An upload token is base64url(payload) followed by its signature, so a client can read every claim out of its own token, and the two R2 init routes hand their reservation ids to the client outright. The asset route takes a reservation id from the request body and deleted it on the strength of that id and the billed user alone, and every hold an account owns is billed to the same user. So a caller could start a Bunny upload, read the id out of the token they were just given, quote it while attaching a one byte image or even a bare YouTube link, and have the quota handed back while the upload carried on. Repeat and a trial worth three gigabytes uploads as much as it likes for as long as Bunny takes to report a figure of its own. Signing the id rather than handing it over bought nothing, because signing is not hiding. A hold now records what it was opened for and is only ever consumed by that flow, so naming one is no longer enough to drop it. Guests hold against the workspace owner's quota rather than their own and had no way to give it back: the release was gated on being signed in. Declaring a size and walking away cost the guest nothing and cost the owner their whole remaining allowance for two hours. The guest grant now carries the reservation and the declared size, bound to the Bunny video as well as to ours, so cancelling gives the quota back and costs them the upload it stood for. What a guest can hold without cancelling lapses in half an hour rather than two hours. The in-transaction fallback check counted the account's Bunny storage as zero on a Bunny upload, because the figure was only prefetched for R2 providers and that branch was unreachable for Bunny until this PR made it reachable. On an account whose storage is all Bunny that was a check that could not fail. It is prefetched for every provider that can reach the fallback now.
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import crypto from 'crypto';
|
|
import { NextRequest } from 'next/server';
|
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
|
import { rateLimit } from '@/lib/rate-limit';
|
|
import {
|
|
createBunnyUploadToken,
|
|
readBunnyUploadGrant,
|
|
type BunnyUploadGrant,
|
|
} from '@/lib/bunny-upload-token';
|
|
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
|
import {
|
|
createGuestUploadToken,
|
|
deriveGuestUploadContext,
|
|
enforceGuestUploadQuota,
|
|
readGuestUploadGrant,
|
|
type GuestUploadGrant,
|
|
} from '@/lib/guest-upload-token';
|
|
import { getMaxVideoUploadBytes, isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
|
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
|
import { logError } from '@/lib/logger';
|
|
import {
|
|
enforceStorageQuota,
|
|
releaseStorageReservation,
|
|
reserveStorageQuota,
|
|
UPLOAD_RESERVATION_PURPOSES,
|
|
} from '@/lib/storage-quota';
|
|
import { parseDeclaredUploadSize } from '@/lib/upload-size';
|
|
|
|
type RouteParams = { params: Promise<{ videoId: string }> };
|
|
|
|
// Matches the project video path: long enough to outlive a slow upload and
|
|
// Bunny's own reporting delay.
|
|
const BUNNY_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* A guest's hold lapses sooner than a member's.
|
|
*
|
|
* A guest is whoever opened the share link, and the hold is written against the
|
|
* workspace owner's quota rather than their own. Declaring a size and then
|
|
* walking away costs the guest nothing and costs the owner their whole remaining
|
|
* allowance, which on a trial is the entire account. Half an hour is the same
|
|
* window the R2 attachment paths already accept, and it bounds what a guest who
|
|
* never uploads can take away. A guest whose upload outruns it loses only the
|
|
* concurrency guard for the tail of the transfer; the bytes are still recorded
|
|
* from the signed size when the asset is created.
|
|
*/
|
|
const GUEST_BUNNY_RESERVATION_TTL_MS = 30 * 60 * 1000;
|
|
|
|
// POST /api/videos/[videoId]/assets/bunny-init
|
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const limited = await rateLimit(request, 'asset-bunny-init');
|
|
if (limited) return limited;
|
|
|
|
const { videoId } = await params;
|
|
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
|
|
if (!context) return apiErrors.notFound('Video');
|
|
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
|
|
|
|
const body = await request.json().catch(() => null);
|
|
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
|
if (!title) return apiErrors.badRequest('Title is required');
|
|
|
|
if (!isBunnyUploadsEnabled()) {
|
|
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
|
}
|
|
|
|
// See the project video route for why the client's declared size is asked for
|
|
// and what it is worth: it buys an honest refusal before the upload starts,
|
|
// and a reservation that concurrent uploads can see.
|
|
const declaredSize = parseDeclaredUploadSize(body?.sizeBytes, getMaxVideoUploadBytes());
|
|
if ('error' in declaredSize) {
|
|
return apiErrors.badRequest(declaredSize.error);
|
|
}
|
|
|
|
const billedUserId = context.video.project.workspace.ownerId;
|
|
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
|
|
if (quotaError) return quotaError;
|
|
|
|
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
|
if (!context.viewerUserId) {
|
|
const quotaError = await enforceGuestUploadQuota(
|
|
request,
|
|
context.video.id,
|
|
'bunny',
|
|
shareSession?.token ?? null
|
|
);
|
|
if (quotaError) return quotaError;
|
|
}
|
|
|
|
const reserveResult = await reserveStorageQuota(
|
|
billedUserId,
|
|
declaredSize.sizeBytes,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
|
context.viewerUserId ? BUNNY_RESERVATION_TTL_MS : GUEST_BUNNY_RESERVATION_TTL_MS
|
|
);
|
|
if ('error' in reserveResult) return reserveResult.error;
|
|
const { reservationId } = reserveResult;
|
|
|
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
|
const libraryId =
|
|
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
|
if (!apiKey || !libraryId) {
|
|
await releaseStorageReservation(
|
|
reservationId,
|
|
billedUserId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
|
}
|
|
|
|
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
|
|
method: 'POST',
|
|
headers: {
|
|
AccessKey: apiKey,
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
|
|
if (!bunnyRes.ok) {
|
|
await releaseStorageReservation(
|
|
reservationId,
|
|
billedUserId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
logError('Failed to create Bunny Stream video asset', await bunnyRes.text());
|
|
return apiErrors.internalError('Failed to initialize Bunny upload');
|
|
}
|
|
|
|
const bunnyVideo = await bunnyRes.json();
|
|
const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : '';
|
|
if (!bunnyVideoId || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
|
|
await releaseStorageReservation(
|
|
reservationId,
|
|
billedUserId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
return apiErrors.internalError('Upload provider did not return a valid video identifier');
|
|
}
|
|
|
|
const expirationTime = Math.floor(Date.now() / 1000) + 3600;
|
|
const hash = crypto.createHash('sha256');
|
|
hash.update(libraryId + apiKey + expirationTime + bunnyVideoId);
|
|
const signature = hash.digest('hex');
|
|
|
|
let uploadToken = '';
|
|
if (context.viewerUserId) {
|
|
uploadToken = createBunnyUploadToken(
|
|
{
|
|
userId: context.viewerUserId,
|
|
projectId: context.video.projectId,
|
|
videoId: bunnyVideoId,
|
|
reservationId,
|
|
declaredSizeBytes: declaredSize.sizeBytes,
|
|
},
|
|
3600
|
|
);
|
|
} else {
|
|
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
|
if (!expectedContext) {
|
|
await releaseStorageReservation(
|
|
reservationId,
|
|
billedUserId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
return apiErrors.forbidden('Missing trusted client IP header');
|
|
}
|
|
|
|
// The guest grant carries the same three claims the signed-in one does,
|
|
// and is bound to the Bunny video as well as to ours. That binding is what
|
|
// makes releasing safe on the guest's say-so: presenting this token to
|
|
// cancel deletes the upload it stands for, so it cannot be used to drop the
|
|
// hold while the transfer carries on.
|
|
uploadToken = createGuestUploadToken(
|
|
{
|
|
projectId: context.video.projectId,
|
|
videoId: context.video.id,
|
|
intent: 'bunny',
|
|
context: expectedContext,
|
|
providerVideoId: bunnyVideoId,
|
|
reservationId,
|
|
declaredSizeBytes: declaredSize.sizeBytes,
|
|
},
|
|
3600
|
|
);
|
|
}
|
|
|
|
const response = successResponse({
|
|
videoId: bunnyVideoId,
|
|
libraryId,
|
|
signature,
|
|
expirationTime,
|
|
uploadToken,
|
|
});
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error initializing Bunny asset upload:', error);
|
|
return apiErrors.internalError('Failed to initialize asset upload');
|
|
}
|
|
}
|
|
|
|
// DELETE /api/videos/[videoId]/assets/bunny-init
|
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const limited = await rateLimit(request, 'asset-bunny-init');
|
|
if (limited) return limited;
|
|
|
|
const { videoId } = await params;
|
|
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
|
|
if (!context) return apiErrors.notFound('Video');
|
|
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
|
|
|
|
const body = await request.json().catch(() => null);
|
|
const bunnyVideoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
|
|
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
|
if (!bunnyVideoId || !uploadToken || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
|
|
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) {
|
|
grant = readBunnyUploadGrant(uploadToken, {
|
|
userId: context.viewerUserId,
|
|
projectId: context.video.projectId,
|
|
videoId: bunnyVideoId,
|
|
});
|
|
} else {
|
|
const shareSession = getShareSessionFromRequest(request, context.video.id);
|
|
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
|
if (!expectedContext) {
|
|
return apiErrors.forbidden('Missing trusted client IP header');
|
|
}
|
|
|
|
grant = readGuestUploadGrant(
|
|
uploadToken,
|
|
{
|
|
projectId: context.video.projectId,
|
|
videoId: context.video.id,
|
|
intent: 'bunny',
|
|
context: expectedContext,
|
|
},
|
|
bunnyVideoId
|
|
);
|
|
}
|
|
|
|
if (!grant) {
|
|
return apiErrors.forbidden('Invalid Bunny upload token');
|
|
}
|
|
|
|
await releaseStorageReservation(
|
|
grant.reservationId,
|
|
context.video.project.workspace.ownerId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
|
|
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]);
|
|
const response = successResponse({ message: 'Pending upload cleaned up' });
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error cleaning up Bunny asset upload:', error);
|
|
return apiErrors.internalError('Failed to cleanup pending upload');
|
|
}
|
|
}
|