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
@@ -5,6 +5,7 @@ import { GuestGate } from '@/components/guest-gate';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { ProjectContentClient } from './project-content-client';
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
@@ -141,6 +142,9 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
};
});
const directUploadsEnabled = isDirectFileUploadEnabled();
const directUploadProvider = isS3VideoUploadsEnabled() ? 'r2' : 'bunny';
const canEdit =
access.canEdit &&
(isOwner ||
@@ -181,6 +185,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
workspaceRole={null}
totalPages={totalPages}
currentPage={page}
directUploadsEnabled={directUploadsEnabled}
directUploadProvider={directUploadProvider}
/>
</div>
</GuestGate>
@@ -208,6 +214,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
workspaceRole={workspaceRole}
totalPages={totalPages}
currentPage={page}
directUploadsEnabled={directUploadsEnabled}
directUploadProvider={directUploadProvider}
/>
</div>
);
@@ -21,6 +21,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { VideoCard } from '@/components/video-card';
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
import type { DirectUploadProvider } from '@/components/video-page/types';
interface SerializedVideo {
id: string;
@@ -48,6 +49,8 @@ interface ProjectContentClientProps {
workspaceRole: string | null;
totalPages: number;
currentPage: number;
directUploadsEnabled: boolean;
directUploadProvider: DirectUploadProvider;
}
export function ProjectContentClient({
@@ -58,6 +61,8 @@ export function ProjectContentClient({
isOwner,
totalPages,
currentPage,
directUploadsEnabled,
directUploadProvider,
}: ProjectContentClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -91,7 +96,8 @@ export function ProjectContentClient({
<VideoDragDropUploader
fixedProjectId={projectId}
fixedProjectName={project.name}
canUpload={canEdit}
canUpload={canEdit && directUploadsEnabled}
directUploadProvider={directUploadProvider}
/>
{/* Project Header */}
@@ -1,6 +1,6 @@
import { VideoPageContent } from '@/components/video-page-content';
import { auth } from '@/lib/auth';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import { requireVideoProjectAccessOrRedirect } from '@/lib/route-access';
interface VideoPageProps {
@@ -24,7 +24,8 @@ export default async function VideoPage({ params }: VideoPageProps) {
mode="dashboard"
videoId={videoId}
projectId={projectId}
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
directUploadsEnabled={isDirectFileUploadEnabled()}
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
/>
);
}
@@ -26,6 +26,8 @@ import {
type VideoSource,
} from '@/lib/video-providers';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
import type { DirectUploadProvider } from '@/components/video-page/types';
import * as tus from 'tus-js-client';
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
@@ -38,10 +40,12 @@ function isVideoFile(file: File): boolean {
export default function NewVideoPageClient({
projectId,
bunnyUploadsEnabled,
directUploadsEnabled,
directUploadProvider,
}: {
projectId: string;
bunnyUploadsEnabled: boolean;
directUploadsEnabled: boolean;
directUploadProvider: DirectUploadProvider;
}) {
const router = useRouter();
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
@@ -64,6 +68,9 @@ export default function NewVideoPageClient({
const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState<string | null>(null);
const pendingBunnyVideoIdRef = useRef<string | null>(null);
const pendingBunnyUploadTokenRef = useRef<string | null>(null);
const pendingR2ObjectKeyRef = useRef<string | null>(null);
const pendingR2UploadTokenRef = useRef<string | null>(null);
const pendingR2ReservationIdRef = useRef<string | null>(null);
const activeTusUploadRef = useRef<tus.Upload | null>(null);
const fileDragDepthRef = useRef(0);
@@ -394,7 +401,7 @@ export default function NewVideoPageClient({
finalThumbnailUrl = getThumbnailUrl(videoSource, 'large');
finalDuration = videoSource.metadata?.duration || null;
} else {
if (!bunnyUploadsEnabled) {
if (!directUploadsEnabled) {
throw new Error('Direct uploads are disabled by this host');
}
@@ -405,22 +412,37 @@ export default function NewVideoPageClient({
}
finalTitle = finalTitle || selectedFile.name;
// Handle TUS Upload
const bunnyData = await uploadToBunny(selectedFile);
uploadedBunnyVideoId = bunnyData.videoId;
uploadedBunnyUploadToken = bunnyData.uploadToken;
if (directUploadProvider === 'r2') {
const r2Data = await uploadVideoToR2(projectId, selectedFile, {
onProgress: (progress) => {
setUploadProgress(progress);
setUploadStatus(`Uploading... ${progress}%`);
},
});
pendingR2ObjectKeyRef.current = r2Data.objectKey;
pendingR2UploadTokenRef.current = r2Data.uploadToken;
pendingR2ReservationIdRef.current = r2Data.reservationId;
finalVideoUrl = bunnyData.url;
finalProviderId = bunnyData.providerId;
finalVideoId = bunnyData.videoId;
// Bunny will generate thumbnails automatically after processing.
// We'll just provide the standard CDN thumbnail URL format as fallback.
finalThumbnailUrl = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg`
: null;
finalVideoUrl = r2Data.proxyUrl;
finalProviderId = 'r2';
finalVideoId = r2Data.objectKey;
finalThumbnailUrl = r2Data.thumbnailUrl || '/placeholder-video-thumbnail.png';
finalDuration = r2Data.duration;
uploadedBunnyUploadToken = r2Data.uploadToken;
} else {
const bunnyData = await uploadToBunny(selectedFile);
uploadedBunnyVideoId = bunnyData.videoId;
uploadedBunnyUploadToken = bunnyData.uploadToken;
finalVideoUrl = bunnyData.url;
finalProviderId = bunnyData.providerId;
finalVideoId = bunnyData.videoId;
finalThumbnailUrl = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg`
: null;
}
}
// Final POST to our database
const response = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -433,6 +455,8 @@ export default function NewVideoPageClient({
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
uploadToken: uploadedBunnyUploadToken,
objectKey: pendingR2ObjectKeyRef.current,
reservationId: pendingR2ReservationIdRef.current,
}),
});
@@ -441,12 +465,21 @@ export default function NewVideoPageClient({
setSubmitError(data.error || 'Failed to add video');
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
} else if (pendingR2ObjectKeyRef.current && pendingR2UploadTokenRef.current) {
await cleanupPendingR2VideoUpload(projectId, {
objectKey: pendingR2ObjectKeyRef.current,
uploadToken: pendingR2UploadTokenRef.current,
reservationId: pendingR2ReservationIdRef.current,
});
}
return;
}
pendingBunnyVideoIdRef.current = null;
pendingBunnyUploadTokenRef.current = null;
pendingR2ObjectKeyRef.current = null;
pendingR2UploadTokenRef.current = null;
pendingR2ReservationIdRef.current = null;
setPendingBunnyVideoId(null);
setPendingBunnyUploadToken(null);
router.push(`/projects/${projectId}`);
@@ -458,6 +491,12 @@ export default function NewVideoPageClient({
pendingBunnyVideoIdRef.current,
pendingBunnyUploadTokenRef.current
);
} else if (pendingR2ObjectKeyRef.current && pendingR2UploadTokenRef.current) {
await cleanupPendingR2VideoUpload(projectId, {
objectKey: pendingR2ObjectKeyRef.current,
uploadToken: pendingR2UploadTokenRef.current,
reservationId: pendingR2ReservationIdRef.current,
});
}
} finally {
activeTusUploadRef.current = null;
@@ -492,7 +531,7 @@ export default function NewVideoPageClient({
<CardHeader>
<CardTitle>Add Video</CardTitle>
<CardDescription>
{bunnyUploadsEnabled
{directUploadsEnabled
? 'Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.'
: 'Paste a video link to add it to your project. Direct uploads are disabled on this host.'}
</CardDescription>
@@ -504,12 +543,12 @@ export default function NewVideoPageClient({
className="mb-6"
>
<TabsList
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
className={`grid w-full ${directUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
>
<TabsTrigger value="url" disabled={isLoading}>
Paste URL
</TabsTrigger>
{bunnyUploadsEnabled ? (
{directUploadsEnabled ? (
<TabsTrigger value="file" disabled={isLoading}>
Direct Upload
</TabsTrigger>
@@ -1,5 +1,5 @@
import { requireProjectAccessOrRedirect } from '@/lib/route-access';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import NewVideoPageClient from './new-video-page-client';
interface NewVideoPageProps {
@@ -14,5 +14,11 @@ export default async function NewVideoPage({ params }: NewVideoPageProps) {
intent: 'manage',
});
return <NewVideoPageClient projectId={projectId} bunnyUploadsEnabled={isBunnyUploadsEnabled()} />;
return (
<NewVideoPageClient
projectId={projectId}
directUploadsEnabled={isDirectFileUploadEnabled()}
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
/>
);
}