feat: harden Bunny upload flow, migrate Bunny playback to hls.js, and add Bunny storage admin stats

This commit is contained in:
Yusuf İpek
2026-02-22 13:03:45 +03:00
parent 10164069dd
commit e30b4a5b19
17 changed files with 1445 additions and 285 deletions
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
@@ -125,6 +126,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const wasActive = result.version.isActive;
// Delete Bunny provider asset for this version before DB deletion.
await cleanupBunnyStreamVideos([{
providerId: result.version.providerId,
videoId: result.version.videoId,
}]);
// Delete the version (cascades to comments)
await db.videoVersion.delete({ where: { id: versionId } });
@@ -6,6 +6,7 @@ import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -98,7 +99,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const body = await request.json();
const { videoUrl, providerId, providerVideoId, versionLabel, thumbnailUrl, duration, setActive } = body;
const {
videoUrl,
providerId,
providerVideoId,
versionLabel,
thumbnailUrl,
duration,
setActive,
uploadToken
} = body;
if (!videoUrl) {
return apiErrors.badRequest('Video URL is required');
@@ -115,6 +125,27 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest(thumbnailUrlError);
}
const normalizedProviderId = typeof providerId === 'string' && providerId.trim()
? providerId.trim().toLowerCase()
: 'youtube';
const normalizedProviderVideoId = typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
if (normalizedProviderId === 'bunny') {
if (!normalizedProviderVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedProviderVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
// Use transaction to handle active flag
@@ -131,8 +162,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
data: {
versionNumber: nextVersionNumber,
versionLabel: versionLabel?.trim() || null,
providerId: providerId || 'youtube',
videoId: providerVideoId || '',
providerId: normalizedProviderId,
videoId: normalizedProviderVideoId,
originalUrl: videoUrl,
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
thumbnailUrl: thumbnailUrl || null,