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
@@ -2,6 +2,7 @@
import { ProjectFilter } from './project-filter';
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
import type { DirectUploadProvider } from '@/components/video-page/types';
interface SerializedProject {
id: string;
@@ -21,7 +22,8 @@ interface DashboardClientProps {
totalPages: number;
canCreateProjects: boolean;
canUploadVideos: boolean;
bunnyUploadsEnabled: boolean;
directUploadsEnabled: boolean;
directUploadProvider: DirectUploadProvider;
}
export function DashboardClient({
@@ -30,11 +32,15 @@ export function DashboardClient({
totalPages,
canCreateProjects,
canUploadVideos,
bunnyUploadsEnabled,
directUploadsEnabled,
directUploadProvider,
}: DashboardClientProps) {
return (
<div className="px-6 lg:px-8 py-8 w-full">
<VideoDragDropUploader canUpload={canUploadVideos && bunnyUploadsEnabled} />
<VideoDragDropUploader
canUpload={canUploadVideos && directUploadsEnabled}
directUploadProvider={directUploadProvider}
/>
<ProjectFilter
projects={serializedProjects}
workspaces={workspaces}
+3 -2
View File
@@ -8,7 +8,7 @@ import {
} from '@/lib/route-access';
import { DashboardClient } from './dashboard-client';
import { buildBillingAccessWhereInput } from '@/lib/billing';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
export default async function DashboardPage({
searchParams,
@@ -157,7 +157,8 @@ export default async function DashboardPage({
totalPages={totalPages}
canCreateProjects={canCreateProjects}
canUploadVideos={canUploadVideos}
bunnyUploadsEnabled={isBunnyUploadsEnabled()}
directUploadsEnabled={isDirectFileUploadEnabled()}
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
/>
);
}
@@ -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'}
/>
);
}
@@ -17,6 +17,7 @@ import { Badge } from '@/components/ui/badge';
import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
function VisibilityIcon({ visibility }: { visibility: string }) {
switch (visibility) {
@@ -108,7 +109,8 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP
<div className="px-6 lg:px-8 py-8 w-full">
<VideoDragDropUploader
workspaceId={workspaceId}
canUpload={isAdmin && workspace._count.projects > 0}
canUpload={isAdmin && workspace._count.projects > 0 && isDirectFileUploadEnabled()}
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
/>
{/* Back & Header */}
<div className="mb-6">
@@ -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);
@@ -6,7 +6,7 @@ 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';
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
import { enforceStorageQuota } from '@/lib/storage-quota';
@@ -60,8 +60,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Title is required');
}
if (!isBunnyUploadsFeatureEnabled()) {
return apiErrors.badRequest('Direct uploads are disabled by this host');
if (!isBunnyUploadsEnabled()) {
return apiErrors.badRequest('Bunny direct uploads are disabled by this host');
}
const quotaError = await enforceStorageQuota(project.workspace.ownerId, BigInt(0));
@@ -0,0 +1,298 @@
import { NextRequest } from 'next/server';
import { randomUUID } from 'crypto';
import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import {
createR2UploadToken,
parseR2UploadToken,
verifyR2UploadToken,
} from '@/lib/r2-upload-token';
import {
createPresignedImagePutUrl,
createPresignedVideoPutUrl,
deleteR2Object,
deleteVideoObject,
} from '@/lib/r2';
import { getMaxVideoUploadBytes, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import {
buildVideoObjectKey,
getVideoExtensionFromMime,
resolveVideoContentType,
videoProxyPathFromFilename,
} from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
import {
enforceStorageQuota,
releaseStorageReservation,
reserveStorageQuota,
} from '@/lib/storage-quota';
import { createR2UploadSession } from '@/lib/r2-upload-session';
type RouteParams = { params: Promise<{ projectId: string }> };
const VIDEO_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
const THUMBNAIL_RESERVE_BYTES = BigInt(512 * 1024);
async function getProjectWithEditAccess(projectId: string, userId: string) {
const project = await db.project.findUnique({
where: { id: projectId },
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
workspace: { select: { ownerId: true } },
},
});
if (!project) return null;
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
if (!access.canEdit) return null;
return project;
}
// POST /api/projects/[projectId]/videos/r2-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;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!isS3VideoUploadsEnabled()) {
return apiErrors.badRequest('S3 video uploads are disabled by this host');
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const fileName = typeof body?.fileName === 'string' ? body.fileName.trim() : '';
const contentTypeInput = typeof body?.contentType === 'string' ? body.contentType.trim() : '';
const sizeBytesRaw = body?.sizeBytes;
if (!fileName) {
return apiErrors.badRequest('fileName is required');
}
let sizeBytes: bigint;
try {
sizeBytes = BigInt(sizeBytesRaw);
if (sizeBytes <= BigInt(0)) {
return apiErrors.badRequest('sizeBytes must be a positive integer');
}
} catch {
return apiErrors.badRequest('sizeBytes must be a positive integer');
}
const maxBytes = getMaxVideoUploadBytes();
if (sizeBytes > maxBytes) {
return apiErrors.badRequest('Video file exceeds the maximum allowed upload size');
}
const contentType = resolveVideoContentType(fileName, contentTypeInput);
if (!contentType) {
return apiErrors.badRequest('Unsupported video format');
}
const ext = getVideoExtensionFromMime(contentType);
if (!ext) {
return apiErrors.badRequest('Unsupported video format');
}
const quotaError = await enforceStorageQuota(
project.workspace.ownerId,
sizeBytes + THUMBNAIL_RESERVE_BYTES
);
if (quotaError) return quotaError;
const reserveResult = await reserveStorageQuota(
project.workspace.ownerId,
sizeBytes + THUMBNAIL_RESERVE_BYTES,
VIDEO_RESERVATION_TTL_MS
);
if ('error' in reserveResult) return reserveResult.error;
const fileId = randomUUID();
const filename = `${fileId}.${ext}`;
const objectKey = buildVideoObjectKey(filename);
const proxyUrl = videoProxyPathFromFilename(filename);
const thumbnailFilename = `${fileId}.jpg`;
const thumbnailObjectKey = `images/${thumbnailFilename}`;
const thumbnailProxyUrl = `/api/upload/image/${thumbnailFilename}`;
let presignedPutUrl: string;
let thumbnailPresignedPutUrl: string;
try {
[presignedPutUrl, thumbnailPresignedPutUrl] = await Promise.all([
createPresignedVideoPutUrl(objectKey, contentType, sizeBytes),
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
]);
} catch (error) {
await releaseStorageReservation(reserveResult.reservationId, project.workspace.ownerId);
logError('Failed to create presigned video upload URL:', error);
return apiErrors.internalError('Failed to initialize video upload');
}
const uploadJti = randomUUID();
const expiresAt = new Date(Date.now() + VIDEO_RESERVATION_TTL_MS);
const uploadSession = await createR2UploadSession({
userId: session.user.id,
projectId,
billedUserId: project.workspace.ownerId,
objectKey,
thumbnailObjectKey,
declaredSizeBytes: sizeBytes,
contentType,
reservationId: reserveResult.reservationId,
uploadJti,
expiresAt,
});
const uploadToken = createR2UploadToken({
userId: session.user.id,
projectId,
objectKey,
sessionId: uploadSession.id,
tokenId: uploadJti,
thumbnailObjectKey,
});
const response = successResponse({
presignedPutUrl,
objectKey,
proxyUrl,
uploadToken,
reservationId: reserveResult.reservationId,
contentType,
thumbnailPresignedPutUrl,
thumbnailObjectKey,
thumbnailProxyUrl,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error initializing R2 video upload:', error);
return apiErrors.internalError('Failed to initialize upload');
}
}
// DELETE /api/projects/[projectId]/videos/r2-init
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();
}
if (!isS3VideoUploadsEnabled()) {
return apiErrors.badRequest('S3 video uploads are disabled by this host');
}
const project = await getProjectWithEditAccess(projectId, session.user.id);
if (!project) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json().catch(() => null);
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
const thumbnailObjectKey =
typeof body?.thumbnailObjectKey === 'string' ? body.thumbnailObjectKey.trim() : '';
if (!objectKey || !uploadToken) {
return apiErrors.badRequest('objectKey and uploadToken are required');
}
const tokenPayload = parseR2UploadToken(uploadToken);
if (!tokenPayload) {
return apiErrors.forbidden('Invalid upload token');
}
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
userId: session.user.id,
projectId,
objectKey,
sessionId: tokenPayload.sid,
tokenId: tokenPayload.jti,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid upload token');
}
const uploadSession = await db.videoUploadSession.findFirst({
where: {
id: tokenPayload.sid,
status: 'INITIATED',
userId: session.user.id,
projectId,
objectKey,
uploadJti: tokenPayload.jti,
expiresAt: { gt: new Date() },
},
select: {
id: true,
reservationId: true,
billedUserId: true,
thumbnailObjectKey: true,
},
});
if (!uploadSession) {
return apiErrors.forbidden('Invalid upload token');
}
if (thumbnailObjectKey && thumbnailObjectKey !== uploadSession.thumbnailObjectKey) {
return apiErrors.badRequest('Invalid thumbnail object key');
}
const cancelled = await db.videoUploadSession.updateMany({
where: {
id: uploadSession.id,
status: 'INITIATED',
},
data: {
status: 'CANCELLED',
consumedAt: new Date(),
},
});
if (cancelled.count !== 1) {
return apiErrors.forbidden('Invalid upload token');
}
try {
await Promise.all([
deleteVideoObject(objectKey),
uploadSession.thumbnailObjectKey.startsWith('images/')
? deleteR2Object(uploadSession.thumbnailObjectKey)
: Promise.resolve(),
]);
} catch (error) {
logError('Failed to delete pending R2 video object:', error);
}
await releaseStorageReservation(uploadSession.reservationId, uploadSession.billedUserId);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error cleaning up pending R2 video upload:', error);
return apiErrors.internalError('Failed to cleanup pending upload');
}
}
+116 -28
View File
@@ -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 }> };
@@ -97,19 +99,30 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
thumbnailUrl,
duration,
uploadToken,
objectKey,
} = body;
if (!title || !videoUrl) {
return apiErrors.badRequest('Title and video URL are required');
}
// 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);
}
@@ -121,6 +134,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const normalizedVideoId = typeof videoId === 'string' ? videoId.trim() : '';
const normalizedUploadToken = typeof uploadToken === 'string' ? uploadToken.trim() : '';
let versionSizeBytes = BigInt(0);
let finalizedR2Session: {
sessionId: string;
reservationId: string | null;
billedUserId: string;
thumbnailProxyUrl: string;
} | null = null;
if (normalizedProviderId === 'bunny') {
if (!normalizedVideoId || !normalizedUploadToken) {
return apiErrors.badRequest('Bunny uploads must include videoId and uploadToken');
@@ -134,8 +155,42 @@ 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;
finalizedR2Session = {
sessionId: finalizeResult.sessionId,
reservationId: finalizeResult.reservationId,
billedUserId: finalizeResult.billedUserId,
thumbnailProxyUrl: finalizeResult.thumbnailProxyUrl,
};
}
const persistedVideoId =
normalizedProviderId === 'r2'
? typeof objectKey === 'string'
? objectKey.trim()
: ''
: normalizedVideoId;
// Get the next position
const lastVideo = await db.video.findFirst({
where: { projectId },
@@ -144,29 +199,62 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const nextPosition = (lastVideo?.position ?? -1) + 1;
// Create video with initial version
const video = await db.video.create({
data: {
title: title.trim(),
description: description?.trim() || null,
position: nextPosition,
projectId,
versions: {
create: {
versionNumber: 1,
providerId: normalizedProviderId,
videoId: normalizedVideoId,
originalUrl: videoUrl,
title: title.trim(),
thumbnailUrl: thumbnailUrl || null,
duration: duration || null,
isActive: true,
const video = await db.$transaction(async (tx) => {
if (finalizedR2Session) {
const consumed = await tx.videoUploadSession.updateMany({
where: {
id: finalizedR2Session.sessionId,
status: 'INITIATED',
userId: session.user.id,
projectId,
objectKey: persistedVideoId,
},
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.video.create({
data: {
title: title.trim(),
description: description?.trim() || null,
position: nextPosition,
projectId,
versions: {
create: {
versionNumber: 1,
providerId: normalizedProviderId,
videoId: persistedVideoId,
originalUrl: videoUrl,
title: title.trim(),
thumbnailUrl:
normalizedProviderId === 'r2'
? (finalizedR2Session?.thumbnailProxyUrl ?? '/placeholder-video-thumbnail.png')
: thumbnailUrl || null,
duration: duration || null,
sizeBytes: versionSizeBytes,
isActive: true,
},
},
},
},
include: {
versions: true,
_count: { select: { versions: true } },
},
include: {
versions: true,
_count: { select: { versions: true } },
},
});
});
// Notify project owner (fire-and-forget, skip if they added it themselves)
@@ -181,7 +269,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}).catch((err) => logError('Notification failed:', err));
}
const response = successResponse(video, 201);
const response = successResponse(toJsonSafe(video), 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating video:', error);
+5 -1
View File
@@ -37,6 +37,9 @@ const MIME_ALIASES: Record<string, string> = {
'audio/x-pn-wav': 'audio/wav',
'audio/mp3': 'audio/mpeg',
'audio/x-mpeg': 'audio/mpeg',
// Some browsers report MediaRecorder audio-only blobs as video/* containers.
'video/webm': 'audio/webm',
'video/mp4': 'audio/mp4',
};
// Map canonical MIME to fallback file extension
@@ -231,7 +234,8 @@ export async function POST(request: NextRequest) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest('File content does not match an audio format');
}
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
const hasValidMagicBytes = hasValidAudioMagicBytes(buffer.slice(0, 16), contentType);
if (!hasValidMagicBytes) {
await releaseStorageReservation(reservationId);
return apiErrors.badRequest('File content does not match the declared audio format');
}
+24 -4
View File
@@ -48,23 +48,43 @@ export async function GET(
projectId: true,
project: { select: projectSelect },
} as const;
const [comment, videoAsset, session] = await Promise.all([
db.comment.findFirst({
const [comments, videoAssets, videoVersions, session] = await Promise.all([
db.comment.findMany({
where: { imageUrl },
take: 2,
select: {
version: {
select: { video: { select: videoSelect } },
},
},
}),
db.videoAsset.findFirst({
db.videoAsset.findMany({
where: { sourceUrl: imageUrl },
take: 2,
select: { video: { select: videoSelect } },
}),
db.videoVersion.findMany({
where: { thumbnailUrl: imageUrl },
take: 2,
select: { video: { select: videoSelect } },
}),
auth(),
]);
const video = comment?.version?.video ?? videoAsset?.video ?? null;
const uniqueVideos = new Map<string, (typeof videoAssets)[number]['video']>();
comments.forEach((comment) => {
if (comment.version?.video) uniqueVideos.set(comment.version.video.id, comment.version.video);
});
videoAssets.forEach((videoAsset) => uniqueVideos.set(videoAsset.video.id, videoAsset.video));
videoVersions.forEach((videoVersion) =>
uniqueVideos.set(videoVersion.video.id, videoVersion.video)
);
if (uniqueVideos.size > 1) {
return apiErrors.forbidden('Access denied');
}
const video = uniqueVideos.values().next().value ?? null;
if (!video) {
return apiErrors.forbidden('Access denied');
}
+112
View File
@@ -0,0 +1,112 @@
import { NextRequest } from 'next/server';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { apiErrors } from '@/lib/api-response';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { buildVideoObjectKey, SAFE_VIDEO_BASENAME } from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
const VIDEO_CONTENT_TYPE_MAP: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/mp4',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
};
function getVideoContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
return VIDEO_CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
if (!SAFE_VIDEO_BASENAME.test(filename)) {
return apiErrors.badRequest('Invalid filename');
}
const originalUrl = `/api/upload/video/${filename}`;
const projectSelect = {
id: true,
ownerId: true,
workspaceId: true,
visibility: true,
} as const;
const videoSelect = {
id: true,
projectId: true,
project: { select: projectSelect },
} as const;
const [versions, session] = await Promise.all([
db.videoVersion.findMany({
where: { originalUrl },
take: 2,
select: {
id: true,
video: { select: videoSelect },
},
}),
auth(),
]);
const uniqueVideos = new Map<string, (typeof versions)[number]['video']>();
for (const version of versions) {
uniqueVideos.set(version.video.id, version.video);
}
if (uniqueVideos.size > 1) {
return apiErrors.forbidden('Access denied');
}
const video = uniqueVideos.values().next().value ?? null;
if (!video) {
return apiErrors.forbidden('Access denied');
}
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: {
hasAccess: false,
canComment: false,
canDownload: false,
allowGuests: false,
requiresPassword: false,
};
if (!shareAccess.hasAccess) {
return apiErrors.forbidden('Access denied');
}
}
const key = buildVideoObjectKey(filename);
return proxyR2MediaObject({
request,
key,
fallbackContentType: getVideoContentType(filename),
cacheControl: 'private, max-age=3600',
internalErrorMessage: 'Failed to load video',
});
} catch (error) {
logError('Error serving video upload:', error);
return apiErrors.internalError('Failed to load video');
}
}