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:
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user