mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: enable S3 video uploads and update related configurations
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES. - Updated .env.example and .env.docker.example to reflect new configuration options. - Enhanced Content Security Policy to include origins for S3-compatible storage. - Updated dependencies for AWS SDK to support new features. - Refactored upload logic to accommodate both Bunny and S3 upload providers. - Updated documentation to clarify the usage of direct uploads and S3 configurations. - Closes #11
This commit is contained in:
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
@@ -148,8 +149,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
});
|
||||
|
||||
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult };
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
|
||||
result.version.providerId === 'r2'
|
||||
? deleteMediaFilesBestEffort(
|
||||
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
|
||||
Boolean(url)
|
||||
)
|
||||
)
|
||||
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
|
||||
]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation';
|
||||
import { toJsonSafe } from '@/lib/json-serialize';
|
||||
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';
|
||||
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
@@ -88,6 +90,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
duration,
|
||||
setActive,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
} = body;
|
||||
|
||||
if (!videoUrl) {
|
||||
@@ -103,13 +106,23 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URLs use safe schemes (http/https only)
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
const normalizedProviderIdEarly =
|
||||
typeof providerId === 'string' && providerId.trim()
|
||||
? providerId.trim().toLowerCase()
|
||||
: 'youtube';
|
||||
|
||||
if (normalizedProviderIdEarly === 'r2') {
|
||||
if (!videoUrl.startsWith('/api/upload/video/')) {
|
||||
return apiErrors.badRequest('Video URL must be a valid upload path');
|
||||
}
|
||||
} else {
|
||||
const videoUrlError = validateUrl(videoUrl, 'Video URL');
|
||||
if (videoUrlError) {
|
||||
return apiErrors.badRequest(videoUrlError);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL');
|
||||
const thumbnailUrlError = validateOptionalUrlOrAppPath(thumbnailUrl, 'Thumbnail URL');
|
||||
if (thumbnailUrlError) {
|
||||
return apiErrors.badRequest(thumbnailUrlError);
|
||||
}
|
||||
@@ -122,6 +135,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
typeof providerVideoId === 'string' ? providerVideoId.trim() : '';
|
||||
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
|
||||
|
||||
let versionSizeBytes = BigInt(0);
|
||||
let persistedProviderVideoId = normalizedProviderVideoId;
|
||||
let finalizedR2Session: {
|
||||
sessionId: string;
|
||||
reservationId: string | null;
|
||||
billedUserId: string;
|
||||
thumbnailProxyUrl: string;
|
||||
} | null = null;
|
||||
|
||||
if (normalizedProviderId === 'bunny') {
|
||||
if (!normalizedProviderVideoId || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('Bunny uploads must include providerVideoId and uploadToken');
|
||||
@@ -135,6 +157,34 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid Bunny upload token');
|
||||
}
|
||||
} else if (normalizedProviderId === 'r2') {
|
||||
const normalizedObjectKey = typeof objectKey === 'string' ? objectKey.trim() : '';
|
||||
if (!normalizedObjectKey || !normalizedUploadToken) {
|
||||
return apiErrors.badRequest('R2 uploads must include objectKey and uploadToken');
|
||||
}
|
||||
|
||||
const finalizeResult = await finalizeR2VideoUpload({
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
videoUrl,
|
||||
objectKey: normalizedObjectKey,
|
||||
uploadToken: normalizedUploadToken,
|
||||
});
|
||||
if (!finalizeResult.ok) {
|
||||
if (finalizeResult.status === 403) {
|
||||
return apiErrors.forbidden(finalizeResult.error);
|
||||
}
|
||||
return apiErrors.badRequest(finalizeResult.error);
|
||||
}
|
||||
|
||||
versionSizeBytes = finalizeResult.sizeBytes;
|
||||
persistedProviderVideoId = normalizedObjectKey;
|
||||
finalizedR2Session = {
|
||||
sessionId: finalizeResult.sessionId,
|
||||
reservationId: finalizeResult.reservationId,
|
||||
billedUserId: finalizeResult.billedUserId,
|
||||
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1;
|
||||
@@ -150,16 +200,47 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
}
|
||||
|
||||
if (finalizedR2Session) {
|
||||
const consumed = await tx.videoUploadSession.updateMany({
|
||||
where: {
|
||||
id: finalizedR2Session.sessionId,
|
||||
status: 'INITIATED',
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
objectKey: persistedProviderVideoId,
|
||||
},
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (consumed.count !== 1) {
|
||||
throw new Error('Upload session already consumed');
|
||||
}
|
||||
if (finalizedR2Session.reservationId) {
|
||||
await tx.uploadReservation.deleteMany({
|
||||
where: {
|
||||
id: finalizedR2Session.reservationId,
|
||||
billedUserId: finalizedR2Session.billedUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.videoVersion.create({
|
||||
data: {
|
||||
versionNumber: nextVersionNumber,
|
||||
versionLabel: versionLabel?.trim() || null,
|
||||
providerId: normalizedProviderId,
|
||||
videoId: normalizedProviderVideoId,
|
||||
videoId: persistedProviderVideoId,
|
||||
originalUrl: videoUrl,
|
||||
title: versionLabel?.trim() || `Version ${nextVersionNumber}`,
|
||||
thumbnailUrl: thumbnailUrl || null,
|
||||
thumbnailUrl:
|
||||
normalizedProviderId === 'r2'
|
||||
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
|
||||
: thumbnailUrl || null,
|
||||
duration: duration || null,
|
||||
sizeBytes: versionSizeBytes,
|
||||
isActive: setActive ?? false,
|
||||
videoParentId: videoId,
|
||||
},
|
||||
@@ -183,7 +264,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
}
|
||||
|
||||
const response = successResponse(version, 201);
|
||||
const response = successResponse(toJsonSafe(version), 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error creating version:', error);
|
||||
|
||||
Reference in New Issue
Block a user