mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
The per-file ceiling was a flat 5 GiB from the environment, which is both too small for a paying account with 200 GB of storage and unaware of what an upload actually costs. The provider derives its own renditions from the file (1080p, 720p and down) and bills them to the same account, so a file allowed to fill the quota exactly is over it by the time it finishes processing. The ceiling is now 80% of whatever limit the account is held to: 160 GB on the plan, 2.4 GB on a cardless trial, and it moves on its own when either number changes. OPENFRAME_MAX_VIDEO_UPLOAD_BYTES keeps working as an absolute cap for a host that wants one, where the lower of the two applies, and an instance running without billing has no quota to divide and falls back to the flat 5 GiB. The refusal now names the ceiling, which the old one left the client to guess. Finalize re-checks only the host cap. Re-deriving the account's ceiling there would delete a finished upload over a plan that lapsed while the bytes were in flight, and an upload larger than what was declared is already caught by the declared-size check beside it.
244 lines
8.3 KiB
TypeScript
244 lines
8.3 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { db } from '@/lib/db';
|
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
|
import { rateLimit } from '@/lib/rate-limit';
|
|
import crypto from 'crypto';
|
|
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
|
import { createBunnyUploadToken, readBunnyUploadGrant } from '@/lib/bunny-upload-token';
|
|
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
|
import { logError } from '@/lib/logger';
|
|
import {
|
|
enforceStorageQuota,
|
|
getMaxVideoUploadBytesForUser,
|
|
releaseStorageReservation,
|
|
reserveStorageQuota,
|
|
UPLOAD_RESERVATION_PURPOSES,
|
|
} from '@/lib/storage-quota';
|
|
import { parseDeclaredUploadSize } from '@/lib/upload-size';
|
|
|
|
type RouteParams = { params: Promise<{ projectId: string }> };
|
|
|
|
// Long enough to outlive a slow upload and Bunny's own reporting delay, matching
|
|
// what the R2 video path already reserves for. The reservation is what makes
|
|
// concurrent uploads visible to each other, so it has to stay until the bytes it
|
|
// stands for are counted, not until the upload finishes.
|
|
const BUNNY_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
|
|
|
async function getProjectWithEditAccess(projectId: string, userId: string) {
|
|
const project = await db.project.findUnique({
|
|
where: { id: projectId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
ownerId: true,
|
|
workspaceId: true,
|
|
visibility: true,
|
|
workspace: { select: { ownerId: true } },
|
|
},
|
|
});
|
|
|
|
if (!project) return null;
|
|
|
|
const access = await checkProjectAccess(project, userId);
|
|
const canEdit = access.canEdit;
|
|
|
|
if (!canEdit) return null;
|
|
|
|
return project;
|
|
}
|
|
|
|
// POST /api/projects/[projectId]/videos/bunny-init
|
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const limited = await rateLimit(request, 'mutate');
|
|
if (limited) return limited;
|
|
|
|
const session = await auth();
|
|
const { projectId } = await params;
|
|
|
|
if (!session?.user?.id) {
|
|
return apiErrors.unauthorized();
|
|
}
|
|
|
|
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
|
if (!project) {
|
|
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('Bunny direct uploads are disabled by this host');
|
|
}
|
|
|
|
// 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 billedUserId = project.workspace.ownerId;
|
|
const declaredSize = parseDeclaredUploadSize(
|
|
body?.sizeBytes,
|
|
await getMaxVideoUploadBytesForUser(billedUserId)
|
|
);
|
|
if ('error' in declaredSize) {
|
|
return apiErrors.badRequest(declaredSize.error);
|
|
}
|
|
|
|
const quotaError = await enforceStorageQuota(billedUserId, declaredSize.sizeBytes);
|
|
if (quotaError) return quotaError;
|
|
|
|
const reserveResult = await reserveStorageQuota(
|
|
billedUserId,
|
|
declaredSize.sizeBytes,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY,
|
|
BUNNY_RESERVATION_TTL_MS
|
|
);
|
|
if ('error' in reserveResult) return reserveResult.error;
|
|
const { reservationId } = reserveResult;
|
|
|
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
|
const libraryId =
|
|
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
|
|
|
if (!apiKey || !libraryId) {
|
|
await releaseStorageReservation(
|
|
reservationId,
|
|
billedUserId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
|
}
|
|
|
|
// 1. Create video object in Bunny Stream
|
|
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', await bunnyRes.text());
|
|
return apiErrors.internalError('Failed to initialize video upload with provider');
|
|
}
|
|
|
|
const bunnyVideo = await bunnyRes.json();
|
|
const videoId = bunnyVideo.guid;
|
|
if (typeof videoId !== 'string' || videoId.length === 0) {
|
|
await releaseStorageReservation(
|
|
reservationId,
|
|
billedUserId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
return apiErrors.internalError('Upload provider did not return a valid video identifier');
|
|
}
|
|
|
|
// 2. Generate TUS upload signature
|
|
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
|
|
|
|
// SHA256(library_id + api_key + expiration_time + video_id)
|
|
const hash = crypto.createHash('sha256');
|
|
hash.update(libraryId + apiKey + expirationTime + videoId);
|
|
const signature = hash.digest('hex');
|
|
const uploadToken = createBunnyUploadToken(
|
|
{
|
|
userId: session.user.id,
|
|
projectId,
|
|
videoId,
|
|
reservationId,
|
|
declaredSizeBytes: declaredSize.sizeBytes,
|
|
},
|
|
3600
|
|
);
|
|
|
|
const response = successResponse({
|
|
videoId,
|
|
libraryId,
|
|
signature,
|
|
expirationTime,
|
|
uploadToken,
|
|
});
|
|
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error initializing Bunny upload:', error);
|
|
return apiErrors.internalError('Failed to initialize upload');
|
|
}
|
|
}
|
|
|
|
// DELETE /api/projects/[projectId]/videos/bunny-init
|
|
// Best-effort cleanup for interrupted uploads before a DB row is created.
|
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const limited = await rateLimit(request, 'mutate');
|
|
if (limited) return limited;
|
|
|
|
const session = await auth();
|
|
const { projectId } = await params;
|
|
|
|
if (!session?.user?.id) {
|
|
return apiErrors.unauthorized();
|
|
}
|
|
|
|
const project = await getProjectWithEditAccess(projectId, session.user.id);
|
|
if (!project) {
|
|
return apiErrors.forbidden('Access denied');
|
|
}
|
|
|
|
const body = await request.json().catch(() => null);
|
|
const videoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
|
|
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
|
|
|
if (!videoId || !uploadToken) {
|
|
return apiErrors.badRequest('videoId and uploadToken are required');
|
|
}
|
|
|
|
const grant = readBunnyUploadGrant(uploadToken, {
|
|
userId: session.user.id,
|
|
projectId,
|
|
videoId,
|
|
});
|
|
if (!grant) {
|
|
return apiErrors.forbidden('Invalid Bunny upload token');
|
|
}
|
|
|
|
// Giving the quota back here rather than waiting for the reservation to
|
|
// lapse: an abandoned upload that keeps holding gigabytes for two hours is
|
|
// most of a trial's whole allowance, and the account has nothing to show for
|
|
// it. Safe to do on a caller's say-so only because the reservation id rides
|
|
// inside the signed token next to this video id, so releasing it costs the
|
|
// caller the video it belongs to.
|
|
await releaseStorageReservation(
|
|
grant.reservationId,
|
|
project.workspace.ownerId,
|
|
UPLOAD_RESERVATION_PURPOSES.BUNNY
|
|
);
|
|
|
|
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
|
|
|
|
const response = successResponse({ message: 'Pending upload cleaned up' });
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error cleaning up pending Bunny upload:', error);
|
|
return apiErrors.internalError('Failed to cleanup pending upload');
|
|
}
|
|
}
|