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
@@ -5,6 +5,7 @@ import { auth, checkProjectAccess } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -200,6 +201,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
versions: {
select: {
providerId: true,
videoId: true,
},
},
project: {
include: {
members: { where: { userId: session.user.id } },
@@ -229,6 +236,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('Only project owner or admin can delete videos');
}
// Delete Bunny provider videos first to avoid orphaned assets.
await cleanupBunnyStreamVideos(video.versions);
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoMediaFiles(videoId);
@@ -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,
@@ -3,12 +3,46 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import crypto from 'crypto';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
type RouteParams = { params: Promise<{ projectId: string }> };
async function getProjectWithEditAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({
where: { id: projectId },
include: {
members: { where: { userId } },
workspace: {
include: {
members: { where: { userId } },
},
},
},
});
if (!project) return null;
const isOwner = project.ownerId === userId;
const membership = project.members[0];
const workspaceMembership = project.workspace.members[0];
const canEdit = isOwner ||
membership?.role === ProjectMemberRole.ADMIN ||
workspaceMembership?.role === WorkspaceMemberRole.ADMIN;
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;
@@ -16,36 +50,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.unauthorized();
}
// Check project access (must be owner, project admin, or workspace admin)
const project = await db.project.findUnique({
where: { id: projectId },
include: {
members: { where: { userId: session.user.id } },
workspace: {
include: {
members: { where: { userId: session.user.id } },
},
},
},
});
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.notFound('Project');
}
const isOwner = project.ownerId === session.user.id;
const membership = project.members[0];
const workspaceMembership = project.workspace.members[0];
const canEdit = isOwner ||
membership?.role === ProjectMemberRole.ADMIN ||
workspaceMembership?.role === WorkspaceMemberRole.ADMIN;
if (!canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { title } = body;
const body = await request.json().catch(() => null);
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) {
return apiErrors.badRequest('Title is required');
@@ -76,6 +87,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const bunnyVideo = await bunnyRes.json();
const videoId = bunnyVideo.guid;
if (typeof videoId !== 'string' || videoId.length === 0) {
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
@@ -84,12 +98,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
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,
}, 3600);
const response = successResponse({
videoId,
libraryId,
signature,
expirationTime
expirationTime,
uploadToken,
});
return withCacheControl(response, 'private, no-store');
@@ -98,3 +118,49 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
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 isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
userId: session.user.id,
projectId,
videoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId }]);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error cleaning up pending Bunny upload:', error);
return apiErrors.internalError('Failed to cleanup pending upload');
}
}
+25 -3
View File
@@ -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 }> };
@@ -103,7 +104,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const body = await request.json();
const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration } = body;
const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration, uploadToken } = body;
if (!title || !videoUrl) {
return apiErrors.badRequest('Title and video URL are required');
@@ -120,6 +121,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 normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
if (normalizedProviderId === 'bunny') {
if (!normalizedVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
}
const isValidUploadToken = verifyBunnyUploadToken(normalizedUploadToken, {
userId: session.user.id,
projectId,
videoId: normalizedVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
// Get the next position
const lastVideo = await db.video.findFirst({
where: { projectId },
@@ -137,8 +159,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
versions: {
create: {
versionNumber: 1,
providerId: providerId || 'youtube',
videoId: videoId || '',
providerId: normalizedProviderId,
videoId: normalizedVideoId,
originalUrl: videoUrl,
title: title.trim(),
thumbnailUrl: thumbnailUrl || null,