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:
yusufipk
2026-05-27 17:04:39 +02:00
parent b6de3a29aa
commit 4bf6e821af
57 changed files with 2707 additions and 436 deletions
@@ -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);