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
+4
View File
@@ -18,6 +18,8 @@ NODE_ENV="production"
# Self-host defaults
OPENFRAME_ENABLE_STRIPE="false"
OPENFRAME_ENABLE_BUNNY_UPLOADS="false"
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="true"
OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
OPENFRAME_REQUIRE_INVITE_CODE="false"
SELF_HOSTED_AUTO_CREATE_BUCKET="true"
@@ -32,6 +34,8 @@ TRUSTED_PROXY_MODE="nginx"
MINIO_ROOT_USER="replace-with-minio-root-user"
MINIO_ROOT_PASSWORD="replace-with-strong-minio-password"
R2_ENDPOINT="http://minio:9000"
# Browser-facing endpoint used for presigned upload URLs (must be reachable from the browser).
R2_PRESIGN_ENDPOINT="http://localhost:9000"
R2_PUBLIC_BASE_URL="http://localhost:9000/openframe"
R2_ACCESS_KEY_ID="replace-with-minio-root-user"
R2_SECRET_ACCESS_KEY="replace-with-strong-minio-password"
+13 -1
View File
@@ -19,6 +19,14 @@ NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
# ============================================================================
OPENFRAME_ENABLE_STRIPE="true"
OPENFRAME_ENABLE_BUNNY_UPLOADS="true"
# Self-hosted direct video uploads to your S3-compatible storage (R2_* vars below).
# Mutually exclusive with Bunny: set OPENFRAME_ENABLE_BUNNY_UPLOADS=false when enabling this.
OPENFRAME_ENABLE_S3_VIDEO_UPLOADS="false"
# Max size per uploaded video file in bytes (default 5GB if unset)
OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
# Direct browser uploads require bucket CORS allowing PUT from your app origin(s).
# Run once after creating the bucket: bun run r2:configure-cors
# Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy.
OPENFRAME_REQUIRE_INVITE_CODE="true"
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
@@ -39,9 +47,13 @@ GITHUB_CLIENT_SECRET="your-github-client-secret"
# FILE STORAGE
# ============================================================================
# Cloudflare R2 (S3-compatible)
# For self-hosted S3-compatible storage such as MinIO, set R2_ENDPOINT and R2_PUBLIC_BASE_URL.
# For self-hosted S3-compatible storage such as MinIO, set:
# - R2_ENDPOINT (server/container endpoint)
# - R2_PRESIGN_ENDPOINT (browser-reachable endpoint for presigned upload URLs)
# - R2_PUBLIC_BASE_URL (public base URL for served files)
R2_ACCOUNT_ID="your-account-id"
R2_ENDPOINT=""
R2_PRESIGN_ENDPOINT=""
R2_PUBLIC_BASE_URL=""
R2_ACCESS_KEY_ID="your-access-key"
R2_SECRET_ACCESS_KEY="your-secret-key"
+6 -5
View File
@@ -21,7 +21,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
- Comment tags, resolved states, and CSV/PDF exports
- Video-linked assets for supporting media and references
- Email and Telegram notifications
- URL-based YouTube video intake plus optional Bunny direct uploads
- URL-based YouTube video intake plus optional direct uploads (Bunny Stream or self-hosted S3)
## Core Workflow
@@ -75,8 +75,8 @@ OpenFrame is built with:
- PostgreSQL
- NextAuth.js
- Tailwind CSS
- MinIO or other S3-compatible object storage for self-hosted media
- Bunny Stream for optional direct video uploads
- MinIO or other S3-compatible object storage for self-hosted media and direct video uploads
- Bunny Stream for optional hosted direct video uploads (mutually exclusive with S3 video uploads)
## Self-Hosting
@@ -177,13 +177,14 @@ OPENFRAME_REQUIRE_INVITE_CODE=false
Behavior when disabled:
- `OPENFRAME_ENABLE_STRIPE=false` disables Stripe checkout and customer portal flows and removes billing-based workspace restrictions.
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides direct-upload entry points. URL-based providers such as YouTube remain available.
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
- `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT=http://localhost:9000` (browser-reachable host).
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
These integrations remain optional for self-hosted deployments and can be enabled later by setting the related environment variables:
- Stripe billing
- Bunny direct uploads
- Bunny direct uploads (hosted) or S3 video uploads via `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS` (self-hosted)
- SMTP for invitation and notification delivery
- Telegram notifications
- External S3-compatible storage such as Cloudflare R2 or another compatible provider instead of bundled MinIO
@@ -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');
}
}
+51 -162
View File
@@ -6,7 +6,8 @@
"name": "openframe",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.1029.0",
"@aws-sdk/client-s3": "3.1054.0",
"@aws-sdk/s3-request-presigner": "3.1054.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
@@ -16,7 +17,7 @@
"gsap": "^3.14.2",
"hls.js": "^1.6.15",
"lucide-react": "^0.563.0",
"next": "16.2.3",
"next": "16.2.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.5",
@@ -82,69 +83,53 @@
"@aws-crypto/util": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/[email protected]29.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-node": "^3.972.30", "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", "@aws-sdk/middleware-expect-continue": "^3.972.9", "@aws-sdk/middleware-flexible-checksums": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-location-constraint": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/middleware-ssec": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.16", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/eventstream-serde-browser": "^4.2.13", "@smithy/eventstream-serde-config-resolver": "^4.3.13", "@smithy/eventstream-serde-node": "^4.2.13", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-blob-browser": "^4.2.14", "@smithy/hash-node": "^4.2.13", "@smithy/hash-stream-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/md5-js": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.15", "tslib": "^2.6.2" } }, "sha512-OuA8RZTxsAaHDcI25j2NGLMaYFI2WpJdDzK3uLmVBmaHwjQKQZOUDVVBcln8pNo3IgkY+HRSJhRR4/xlM//UyQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/[email protected]54.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-node": "^3.972.45", "@aws-sdk/middleware-bucket-endpoint": "^3.972.16", "@aws-sdk/middleware-expect-continue": "^3.972.13", "@aws-sdk/middleware-flexible-checksums": "^3.974.22", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.43", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2ue7uVqaHYX4rytkcrLySYU/m/ZlRbL8KojWefbR24B0/TcFkqN2IovpBFrnmla/dtZAn9eVSlhHeEddOghZ5w=="],
"@aws-sdk/core": ["@aws-sdk/[email protected]3.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="],
"@aws-sdk/core": ["@aws-sdk/[email protected]4.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/[email protected].6", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/[email protected].25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/[email protected].40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/[email protected]7", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/[email protected].42", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-login": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-login": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/[email protected].30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/[email protected].45", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.40", "@aws-sdk/credential-provider-http": "^3.972.42", "@aws-sdk/credential-provider-ini": "^3.972.44", "@aws-sdk/credential-provider-process": "^3.972.40", "@aws-sdk/credential-provider-sso": "^3.972.44", "@aws-sdk/credential-provider-web-identity": "^3.972.44", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/[email protected].25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/[email protected].40", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/token-providers": "3.1026.0", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/token-providers": "3.1054.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/[email protected].29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/[email protected].44", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/[email protected].16", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-FhasMTBDBmMN7EEa1hUeHwo5p5Mv3Dm8w0VEbdXX/6ola/uyhRuJt8zGkH09mLTmab20USTzEpPqyqEoe1MqNg=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/[email protected].13", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/[email protected].7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/crc64-nvme": "^3.972.6", "@aws-sdk/types": "^3.973.7", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/[email protected].22", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-ot1kZ1JGHUxcXPOARhej/n/+Odfx9VPt60pNrUq8Lf/U2blIF3+uj5v56gw76VD70dZvrfeLNo9jKz6pQJfOlA=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CBmixMY36JdAdt9ALgm7yVlvOXGUCHt9Z2kn5p9XVO5StO6HCH+cayV7YYV1CDLsXvVyebaXgBmif9wHoxCeNA=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.12", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg=="],
"@aws-sdk/s3-request-presigner": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rgSDc0LwzM1yUL/UouFLR72HV5lhgdQRQlp8WWWot+q3nhBYrj+mklreWh28q9bGkgrNGWrcpMRp4Y3rC4sxVw=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/[email protected]9", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-retry": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A=="],
"@aws-sdk/nested-clients": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/config-resolver": "^4.4.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA=="],
"@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-endpoints": "^3.3.4", "tslib": "^2.6.2" } }, "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg=="],
"@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-brows[email protected].9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w=="],
"@aws-sdk/xml-builder": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-build[email protected].26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
"@aws/lambda-invoke-store": ["@aws/[email protected]", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
@@ -380,25 +365,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/[email protected].3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
"@next/env": ["@next/[email protected].6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
"@next/eslint-plugin-next": ["@next/[email protected]", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ=="],
"@next/swc-darwin-arm64": ["@next/[email protected].3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
"@next/swc-darwin-arm64": ["@next/[email protected].6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
"@next/swc-darwin-x64": ["@next/[email protected].3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
"@next/swc-darwin-x64": ["@next/[email protected].6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
"@next/swc-linux-arm64-gnu": ["@next/[email protected].3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
"@next/swc-linux-arm64-gnu": ["@next/[email protected].6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
"@next/swc-linux-arm64-musl": ["@next/[email protected].3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
"@next/swc-linux-arm64-musl": ["@next/[email protected].6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
"@next/swc-linux-x64-gnu": ["@next/[email protected].3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
"@next/swc-linux-x64-gnu": ["@next/[email protected].6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
"@next/swc-linux-x64-musl": ["@next/[email protected].3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
"@next/swc-linux-x64-musl": ["@next/[email protected].6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
"@next/swc-win32-arm64-msvc": ["@next/[email protected].3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
"@next/swc-win32-arm64-msvc": ["@next/[email protected].6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
"@next/swc-win32-x64-msvc": ["@next/[email protected].3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
"@next/swc-win32-x64-msvc": ["@next/[email protected].6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
"@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
@@ -406,6 +391,8 @@
"@noble/hashes": ["@noble/[email protected]", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@nodable/entities": ["@nodable/[email protected]", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -574,105 +561,23 @@
"@sindresorhus/merge-streams": ["@sindresorhus/[email protected]", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@smithy/chunked-blob-reader": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@smithy/core": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw=="],
"@smithy/chunked-blob-reader-native": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
"@smithy/credential-provider-imds": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ=="],
"@smithy/core": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg=="],
"@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@smithy/credential-provider-imds": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ=="],
"@smithy/node-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ=="],
"@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
"@smithy/signature-v4": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q=="],
"@smithy/eventstream-serde-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg=="],
"@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA=="],
"@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@smithy/eventstream-serde-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A=="],
"@smithy/eventstream-serde-universal": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA=="],
"@smithy/fetch-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ=="],
"@smithy/hash-blob-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA=="],
"@smithy/hash-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA=="],
"@smithy/hash-stream-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg=="],
"@smithy/invalid-dependency": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg=="],
"@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
"@smithy/md5-js": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ=="],
"@smithy/middleware-content-length": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig=="],
"@smithy/middleware-endpoint": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-serde": "^4.2.17", "@smithy/node-config-provider": "^4.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw=="],
"@smithy/middleware-retry": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/service-error-classification": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.1", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA=="],
"@smithy/middleware-serde": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ=="],
"@smithy/middleware-stack": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw=="],
"@smithy/node-config-provider": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw=="],
"@smithy/node-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA=="],
"@smithy/property-provider": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ=="],
"@smithy/protocol-http": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg=="],
"@smithy/querystring-builder": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ=="],
"@smithy/querystring-parser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA=="],
"@smithy/service-error-classification": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0" } }, "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw=="],
"@smithy/shared-ini-file-loader": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw=="],
"@smithy/signature-v4": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg=="],
"@smithy/smithy-client": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-stack": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ=="],
"@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ=="],
"@smithy/url-parser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw=="],
"@smithy/util-base64": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
"@smithy/util-body-length-browser": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
"@smithy/util-body-length-node": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
"@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@smithy/util-config-provider": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
"@smithy/util-defaults-mode-browser": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw=="],
"@smithy/util-defaults-mode-node": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/config-resolver": "^4.4.14", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ=="],
"@smithy/util-endpoints": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog=="],
"@smithy/util-hex-encoding": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
"@smithy/util-middleware": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow=="],
"@smithy/util-retry": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ=="],
"@smithy/util-stream": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew=="],
"@smithy/util-uri-escape": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
"@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@smithy/util-waiter": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg=="],
"@smithy/uuid": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
"@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -1126,9 +1031,9 @@
"fast-uri": ["[email protected]", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
"fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
@@ -1536,7 +1441,7 @@
"negotiator": ["[email protected]", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next": ["[email protected].3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
"next": ["[email protected].6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
"next-auth": ["[email protected]", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
@@ -1978,6 +1883,8 @@
"wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"xml-naming": ["[email protected]", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
"xtend": ["[email protected]", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["[email protected]", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -2010,18 +1917,12 @@
"@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
"@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@babel/core/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -2178,18 +2079,12 @@
"@aws-crypto/sha1-browser/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@commitlint/config-validator/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@commitlint/top-level/find-up/locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="],
@@ -2248,12 +2143,6 @@
"wrap-ansi/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@commitlint/top-level/find-up/locate-path/p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"@dotenvx/dotenvx/execa/onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
+123 -21
View File
@@ -22,6 +22,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
import type { DirectUploadProvider } from '@/components/video-page/types';
type ProjectOption = {
id: string;
@@ -35,6 +37,7 @@ interface VideoDragDropUploaderProps {
workspaceId?: string;
projectOptions?: ProjectOption[];
canUpload?: boolean;
directUploadProvider?: DirectUploadProvider;
}
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
@@ -68,6 +71,7 @@ export function VideoDragDropUploader({
workspaceId,
projectOptions,
canUpload = false,
directUploadProvider = 'bunny',
}: VideoDragDropUploaderProps) {
const router = useRouter();
@@ -86,11 +90,23 @@ export function VideoDragDropUploader({
);
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
const pendingUploadRef = useRef<{
projectId: string;
videoId: string;
uploadToken: string;
} | null>(null);
const pendingUploadRef = useRef<
| {
type: 'bunny';
projectId: string;
videoId: string;
uploadToken: string;
}
| {
type: 'r2';
projectId: string;
objectKey: string;
uploadToken: string;
reservationId: string | null;
thumbnailObjectKey?: string;
}
| null
>(null);
const cancelRequestedRef = useRef(false);
const dragDepthRef = useRef(0);
const hasLoadedProjectsRef = useRef(false);
@@ -205,11 +221,20 @@ export function VideoDragDropUploader({
if (pending) {
try {
await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
});
if (pending.type === 'bunny') {
await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
});
} else {
await cleanupPendingR2VideoUpload(pending.projectId, {
objectKey: pending.objectKey,
uploadToken: pending.uploadToken,
reservationId: pending.reservationId,
thumbnailObjectKey: pending.thumbnailObjectKey,
});
}
} catch (error) {
console.error('Failed to cleanup cancelled upload:', error);
}
@@ -232,12 +257,80 @@ export function VideoDragDropUploader({
setSelectedProjectId(projectId);
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
let createdVideoId: string | null = null;
let uploadToken: string | null = null;
let pendingCleanup:
| { type: 'bunny'; videoId: string; uploadToken: string }
| {
type: 'r2';
objectKey: string;
uploadToken: string;
reservationId: string | null;
thumbnailObjectKey?: string;
}
| null = null;
try {
const title = getDefaultTitleFromFile(file);
if (directUploadProvider === 'r2') {
const uploaded = await uploadVideoToR2(projectId, file, {
onProgress: (progress) => {
setUploadProgress(progress);
setUploadStatus(`Uploading... ${progress}%`);
},
});
pendingCleanup = {
type: 'r2',
objectKey: uploaded.objectKey,
uploadToken: uploaded.uploadToken,
reservationId: uploaded.reservationId,
thumbnailObjectKey: uploaded.thumbnailObjectKey,
};
pendingUploadRef.current = {
type: 'r2',
projectId,
objectKey: uploaded.objectKey,
uploadToken: uploaded.uploadToken,
reservationId: uploaded.reservationId,
thumbnailObjectKey: uploaded.thumbnailObjectKey,
};
setUploadStatus('Saving video...');
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: null,
videoUrl: uploaded.proxyUrl,
providerId: 'r2',
videoId: uploaded.objectKey,
thumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
duration: uploaded.duration,
uploadToken: uploaded.uploadToken,
objectKey: uploaded.objectKey,
reservationId: uploaded.reservationId,
}),
});
const createPayload = (await createResponse.json().catch(() => null)) as {
error?: string;
} | null;
if (!createResponse.ok) {
throw new Error(createPayload?.error || 'Failed to create video');
}
toast.success(
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
);
setDialogOpen(false);
setDroppedFile(null);
cleanupUploadState();
router.push(`/projects/${projectId}`);
router.refresh();
return;
}
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -259,9 +352,11 @@ export function VideoDragDropUploader({
throw new Error(initPayload?.error || 'Failed to initialize upload');
}
createdVideoId = initPayload.data.videoId;
uploadToken = initPayload.data.uploadToken;
const createdVideoId = initPayload.data.videoId;
const uploadToken = initPayload.data.uploadToken;
pendingCleanup = { type: 'bunny', videoId: createdVideoId, uploadToken };
pendingUploadRef.current = {
type: 'bunny',
projectId,
videoId: createdVideoId,
uploadToken,
@@ -346,13 +441,20 @@ export function VideoDragDropUploader({
return;
}
if (createdVideoId && uploadToken) {
if (pendingCleanup) {
try {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: createdVideoId, uploadToken }),
});
if (pendingCleanup.type === 'bunny') {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: pendingCleanup.videoId,
uploadToken: pendingCleanup.uploadToken,
}),
});
} else {
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
}
} catch (cleanupError) {
console.error('Failed to cleanup pending upload:', cleanupError);
}
@@ -364,7 +466,7 @@ export function VideoDragDropUploader({
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
}
},
[bunnyCdnHostname, cleanupUploadState, projectsById, router]
[bunnyCdnHostname, cleanupUploadState, directUploadProvider, projectsById, router]
);
const handleDropFile = useCallback(
+23 -5
View File
@@ -71,14 +71,16 @@ interface VideoPageContentProps {
mode: VideoPageMode;
videoId: string;
projectId?: string;
bunnyUploadsEnabled?: boolean;
directUploadsEnabled?: boolean;
directUploadProvider?: import('@/components/video-page/types').DirectUploadProvider;
}
export function VideoPageContent({
mode,
videoId,
projectId: propProjectId,
bunnyUploadsEnabled = true,
directUploadsEnabled = false,
directUploadProvider = 'bunny',
}: VideoPageContentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
@@ -200,7 +202,8 @@ export function VideoPageContent({
} = useVersionActions({
projectId: propProjectId,
videoId,
bunnyUploadsEnabled,
directUploadsEnabled,
directUploadProvider,
setVideo,
activeVersionId,
setActiveVersionId,
@@ -282,6 +285,20 @@ export function VideoPageContent({
if (!bunnyCdnHostname) return '';
return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`;
}
if (activeVersion.providerId === 'r2') {
if (activeVersion.originalUrl.startsWith('/api/upload/video/')) {
return activeVersion.originalUrl;
}
if (activeVersion.originalUrl.startsWith('videos/')) {
const filename = activeVersion.originalUrl.slice('videos/'.length);
return `/api/upload/video/${filename}`;
}
if (activeVersion.videoId.startsWith('videos/')) {
const filename = activeVersion.videoId.slice('videos/'.length);
return `/api/upload/video/${filename}`;
}
return activeVersion.originalUrl;
}
try {
const url = new URL(activeVersion.originalUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
@@ -551,7 +568,8 @@ export function VideoPageContent({
const isBunnyVersion = activeVersion?.providerId === 'bunny';
const showBunnyProcessingOverlay =
isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
const isR2Version = activeVersion?.providerId === 'r2';
const showBunnyErrorOverlay = (isBunnyVersion || isR2Version) && bunnyPlaybackState === 'error';
const confirmGuestName = useCallback(() => {
if (!guestName.trim()) return;
@@ -732,7 +750,7 @@ export function VideoPageContent({
onDownload={headerActions.onDownload}
projectId={projectId}
videoId={videoId}
bunnyUploadsEnabled={bunnyUploadsEnabled}
directUploadsEnabled={directUploadsEnabled}
showVersionDialog={showVersionDialog}
setShowVersionDialog={setShowVersionDialog}
newVersionMode={newVersionMode}
+6 -2
View File
@@ -45,7 +45,9 @@ export const DownloadControls = memo(function DownloadControls({
const isVideoDownloadAvailable =
videoCanDownload &&
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
(activeVersion.providerId === 'bunny' ||
activeVersion.providerId === 'direct' ||
activeVersion.providerId === 'r2');
if (activeVersion.providerId === 'bunny') {
return (
@@ -180,7 +182,9 @@ export const DownloadMenuItems = memo(function DownloadMenuItems({
const isVideoDownloadAvailable =
videoCanDownload &&
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
(activeVersion.providerId === 'bunny' ||
activeVersion.providerId === 'direct' ||
activeVersion.providerId === 'r2');
if (activeVersion.providerId === 'bunny') {
return (
@@ -51,6 +51,15 @@ interface UseCommentActionsParams extends CommentActionsConfig {
fetchAssets: () => Promise<void>;
}
function getAudioUploadFilename(blob: Blob): string {
const mime = blob.type.split(';')[0].trim().toLowerCase();
if (mime === 'audio/mp4') return 'recording.m4a';
if (mime === 'audio/ogg' || mime === 'audio/opus') return 'recording.ogg';
if (mime === 'audio/mpeg') return 'recording.mp3';
if (mime === 'audio/wav') return 'recording.wav';
return 'recording.webm';
}
export function useCommentActions({
videoId,
setVideo,
@@ -430,7 +439,8 @@ export function useCommentActions({
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
const blob = new Blob(audioChunksRef.current, { type: recordedMime });
setAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (recordingTimerRef.current) {
@@ -472,7 +482,8 @@ export function useCommentActions({
try {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const uploadFilename = getAudioUploadFilename(audioBlob);
formData.append('audio', audioBlob, uploadFilename);
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
@@ -514,7 +525,7 @@ export function useCommentActions({
let voiceData: { url: string; duration: number } | undefined;
if (audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
formData.append('audio', audioBlob, getAudioUploadFilename(audioBlob));
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
@@ -829,7 +840,8 @@ export function useCommentActions({
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
const blob = new Blob(replyAudioChunksRef.current, { type: recordedMime });
setReplyAudioBlob(blob);
stream.getTracks().forEach((track) => track.stop());
if (replyRecordingTimerRef.current) {
@@ -870,7 +882,7 @@ export function useCommentActions({
setIsUploadingReplyAudio(true);
try {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
@@ -912,7 +924,7 @@ export function useCommentActions({
if (replyAudioBlob) {
const formData = new FormData();
formData.append('audio', replyAudioBlob, 'recording.webm');
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
formData.append('videoId', videoId);
const uploadToken = await getGuestUploadToken('audio');
if (uploadToken) formData.append('uploadToken', uploadToken);
@@ -67,7 +67,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
toast.error('Download is disabled for this shared link');
return;
}
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
if (
activeVersion.providerId !== 'bunny' &&
activeVersion.providerId !== 'direct' &&
activeVersion.providerId !== 'r2'
) {
toast.error('This video source does not support direct download');
return;
}
@@ -97,6 +101,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
}
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
} else if (activeVersion.providerId === 'r2') {
if (!activeVersion.originalUrl.startsWith('/api/upload/video/')) {
throw new Error('Direct download URL is not allowed');
}
downloadUrl = activeVersion.originalUrl;
} else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) {
@@ -113,8 +122,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a');
a.href = downloadUrl;
if (activeVersion.providerId === 'direct') {
a.download = `${baseName}.mp4`;
if (activeVersion.providerId === 'direct' || activeVersion.providerId === 'r2') {
const ext = activeVersion.originalUrl.split('.').pop()?.toLowerCase() || 'mp4';
a.download = `${baseName}.${ext}`;
}
document.body.appendChild(a);
a.click();
@@ -11,6 +11,7 @@ import {
} from '@/lib/video-providers';
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
interface UseVersionActionsParams extends VersionActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>;
@@ -21,7 +22,8 @@ interface UseVersionActionsParams extends VersionActionsConfig {
export function useVersionActions({
projectId,
videoId,
bunnyUploadsEnabled = true,
directUploadsEnabled = false,
directUploadProvider = 'bunny',
setVideo,
activeVersionId,
setActiveVersionId,
@@ -58,13 +60,111 @@ export function useVersionActions({
}
};
const uploadNewVersionFile = async (file: File, title: string) => {
if (!projectId) throw new Error('Missing project');
if (directUploadProvider === 'r2') {
setNewVersionUploadStatus('Initializing upload...');
const uploaded = await uploadVideoToR2(projectId, file, {
onProgress: (progress) => {
setNewVersionUploadProgress(progress);
setNewVersionUploadStatus(`Uploading... ${progress}%`);
},
});
return {
finalVideoUrl: uploaded.proxyUrl,
finalProviderId: 'r2',
finalProviderVideoId: uploaded.objectKey,
finalThumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
finalDuration: uploaded.duration,
uploadToken: uploaded.uploadToken,
objectKey: uploaded.objectKey,
reservationId: uploaded.reservationId,
pendingCleanup: {
objectKey: uploaded.objectKey,
uploadToken: uploaded.uploadToken,
reservationId: uploaded.reservationId,
thumbnailObjectKey: uploaded.thumbnailObjectKey,
},
};
}
setNewVersionUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!initRes.ok) throw new Error('Failed to initialize upload');
const {
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
} = await initRes.json();
await new Promise((resolve, reject) => {
setNewVersionUploadStatus('Uploading video...');
const upload = new tus.Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: signature,
AuthorizationExpire: expirationTime.toString(),
VideoId: bunnyVideoId,
LibraryId: libraryId,
},
metadata: {
filetype: file.type,
title,
},
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
setNewVersionUploadProgress(Number(percentage));
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
setNewVersionUploadStatus('Processing video...');
resolve(true);
},
});
upload.start();
});
return {
finalVideoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`,
finalProviderId: 'bunny',
finalProviderVideoId: bunnyVideoId,
finalThumbnailUrl: bunnyCdnHostname
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
: null,
finalDuration: null as number | null,
uploadToken,
objectKey: null as string | null,
reservationId: null as string | null,
pendingCleanup: {
bunnyVideoId,
uploadToken,
},
};
};
const handleCreateVersion = async () => {
if (!projectId) return;
setIsCreatingVersion(true);
setNewVersionUploadStatus('');
setNewVersionUploadProgress(0);
let uploadedBunnyVideoId: string | null = null;
let uploadedBunnyUploadToken: string | null = null;
let pendingCleanup:
| {
objectKey: string;
uploadToken: string;
reservationId: string | null;
}
| {
bunnyVideoId: string;
uploadToken: string;
}
| null = null;
try {
let finalVideoUrl = '';
@@ -72,6 +172,9 @@ export function useVersionActions({
let finalProviderVideoId = '';
let finalThumbnailUrl: string | null = null;
let finalDuration: number | null = null;
let uploadToken: string | null = null;
let objectKey: string | null = null;
let reservationId: string | null = null;
if (newVersionMode === 'url') {
if (!newVersionSource) throw new Error('Invalid URL');
@@ -82,7 +185,7 @@ export function useVersionActions({
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
finalDuration = meta?.duration || null;
} else {
if (!bunnyUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
if (!directUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
if (!newVersionFile) throw new Error('No file selected');
let title = newVersionFile.name;
if (newVersionLabel.trim()) {
@@ -91,55 +194,16 @@ export function useVersionActions({
title = title.replace(/\.[^/.]+$/, '');
}
setNewVersionUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!initRes.ok) throw new Error('Failed to initialize upload');
const {
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
} = await initRes.json();
uploadedBunnyVideoId = bunnyVideoId;
uploadedBunnyUploadToken = uploadToken;
await new Promise((resolve, reject) => {
setNewVersionUploadStatus('Uploading video...');
const upload = new tus.Upload(newVersionFile, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: signature,
AuthorizationExpire: expirationTime.toString(),
VideoId: bunnyVideoId,
LibraryId: libraryId,
},
metadata: {
filetype: newVersionFile.type,
title,
},
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
setNewVersionUploadProgress(Number(percentage));
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
setNewVersionUploadStatus('Processing video...');
resolve(true);
},
});
upload.start();
});
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
finalProviderId = 'bunny';
finalProviderVideoId = bunnyVideoId;
finalThumbnailUrl = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
: null;
const uploaded = await uploadNewVersionFile(newVersionFile, title);
finalVideoUrl = uploaded.finalVideoUrl;
finalProviderId = uploaded.finalProviderId;
finalProviderVideoId = uploaded.finalProviderVideoId;
finalThumbnailUrl = uploaded.finalThumbnailUrl;
finalDuration = uploaded.finalDuration;
uploadToken = uploaded.uploadToken;
objectKey = uploaded.objectKey;
reservationId = uploaded.reservationId;
pendingCleanup = uploaded.pendingCleanup;
}
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
@@ -149,7 +213,9 @@ export function useVersionActions({
videoUrl: finalVideoUrl,
providerId: finalProviderId,
providerVideoId: finalProviderVideoId,
uploadToken: uploadedBunnyUploadToken,
uploadToken,
objectKey,
reservationId,
versionLabel: newVersionLabel.trim() || null,
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
@@ -180,24 +246,30 @@ export function useVersionActions({
setNewVersionSource(null);
setNewVersionFile(null);
setNewVersionUploadStatus('');
pendingCleanup = null;
} catch (err) {
const errorObj = err as Error;
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: uploadedBunnyVideoId,
uploadToken: uploadedBunnyUploadToken,
}),
}).catch((cleanupError) => {
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
});
if (pendingCleanup && projectId) {
if ('objectKey' in pendingCleanup) {
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
} else {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: pendingCleanup.bunnyVideoId,
uploadToken: pendingCleanup.uploadToken,
}),
}).catch((cleanupError) => {
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
});
}
}
console.error('Failed to create version:', errorObj);
toast.error(errorObj.message || 'Failed to create version');
} finally {
setIsCreatingVersion(false);
setNewVersionUploadProgress(0);
}
};
@@ -209,34 +281,28 @@ export function useVersionActions({
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
{ method: 'DELETE' }
);
if (res.ok) {
setVideo((prev) => {
if (!prev) return prev;
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
return { ...prev, versions: remaining };
});
if (activeVersionId === versionToDelete) {
setVideo((prev) => {
if (!prev) return prev;
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
if (remaining.length > 0) {
setActiveVersionId(remaining[0].id);
} else {
setActiveVersionId(null);
}
return prev;
});
}
setShowDeleteVersionDialog(false);
setVersionToDelete(null);
} else {
const data = await res.json();
toast.error(data.error || 'Failed to delete version');
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Failed to delete version');
}
} catch {
toast.error('Failed to delete version');
setVideo((prev) => {
if (!prev) return prev;
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
if (activeVersionId === versionToDelete && remaining.length > 0) {
const nextActive = remaining.find((v) => v.isActive) ?? remaining[0];
setActiveVersionId(nextActive.id);
}
return { ...prev, versions: remaining };
});
setShowDeleteVersionDialog(false);
setVersionToDelete(null);
toast.success('Version deleted');
} catch (err) {
const errorObj = err as Error;
console.error('Failed to delete version:', errorObj);
toast.error(errorObj.message || 'Failed to delete version');
} finally {
setIsDeletingVersion(false);
}
@@ -259,10 +325,8 @@ export function useVersionActions({
newVersionUploadStatus,
handleNewVersionUrlChange,
handleCreateVersion,
showDeleteVersionDialog,
setShowDeleteVersionDialog,
versionToDelete,
setVersionToDelete,
isDeletingVersion,
handleDeleteVersion,
+129 -2
View File
@@ -204,9 +204,10 @@ export function useVideoPlayer({
if (!activeProviderId) return;
const isYoutube = activeProviderId === 'youtube';
const isBunny = activeProviderId === 'bunny';
const isR2 = activeProviderId === 'r2';
if (isYoutube && !isApiLoaded) return;
if (!isYoutube && !isBunny) return;
if (!isYoutube && !isBunny && !isR2) return;
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
@@ -623,6 +624,132 @@ export function useVideoPlayer({
videoEl.load();
},
};
} else if (isR2) {
const videoEl = videoRef.current;
if (!videoEl) return;
let cachedDuration = 0;
let destroyed = false;
const syncDuration = () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
cachedDuration = videoEl.duration;
setVideoDuration(videoEl.duration);
}
};
const saveProgress = () => {
const current = videoEl.currentTime || 0;
const duration =
Number.isFinite(videoEl.duration) && videoEl.duration > 0
? videoEl.duration
: cachedDuration;
scheduleWatchProgressSaveRef.current({
progress: current,
duration,
immediate: true,
force: true,
});
};
const onLoadedMetadata = () => {
if (destroyed) return;
setBunnyPlaybackState('none');
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
}
setIsReady(true);
syncDuration();
if (!videoEl.paused) {
startBunnyFrameTracking();
}
};
const onPlay = () => {
setIsPlaying(true);
setBunnyPlaybackState('none');
syncDuration();
startBunnyFrameTracking();
};
const onPause = () => {
setIsPlaying(false);
stopBunnyFrameTracking();
saveProgress();
};
const onEnded = () => {
setIsPlaying(false);
stopBunnyFrameTracking();
saveProgress();
};
const onTimeUpdate = () => {
if (!isDraggingRef.current) {
setCurrentTime(videoEl.currentTime || 0);
}
syncDuration();
};
const onVideoError = () => {
if (destroyed) return;
setBunnyPlaybackState('error');
};
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('timeupdate', onTimeUpdate);
videoEl.addEventListener('error', onVideoError);
const playbackSrc =
embedUrl.startsWith('/') && typeof window !== 'undefined'
? `${window.location.origin}${embedUrl}`
: embedUrl;
videoEl.src = playbackSrc;
videoEl.load();
playerRef.current = {
playVideo: () => {
videoEl.play().catch((err) => console.error('Error playing video:', err));
},
pauseVideo: () => videoEl.pause(),
seekTo: (time: number) => {
videoEl.currentTime = time;
},
mute: () => {
videoEl.muted = true;
},
unMute: () => {
videoEl.muted = false;
},
isMuted: () => videoEl.muted,
getCurrentTime: () => videoEl.currentTime || 0,
getDuration: () => {
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
return cachedDuration;
},
getPlayerState: () =>
videoEl.paused
? (window.YT?.PlayerState?.PAUSED ?? 2)
: (window.YT?.PlayerState?.PLAYING ?? 1),
setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate;
},
destroy: () => {
destroyed = true;
stopBunnyFrameTracking();
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('error', onVideoError);
videoEl.removeAttribute('src');
videoEl.load();
},
};
}
};
@@ -633,7 +760,7 @@ export function useVideoPlayer({
} else {
window.onYouTubeIframeAPIReady = initPlayer;
}
} else if (isBunny) {
} else if (isBunny || isR2) {
initPlayer();
}
}, 100);
+4 -2
View File
@@ -170,7 +170,7 @@ export const PlayerCore = memo(function PlayerCore({
onMouseLeave={handleVideoMouseLeave}
>
<div className={cn('relative w-full h-full', isFullscreenMode && 'absolute inset-0')}>
{activeProviderId === 'bunny' ? (
{activeProviderId === 'bunny' || activeProviderId === 'r2' ? (
<div
ref={bunnyViewportRef}
className="absolute inset-0 flex items-center justify-center bg-black"
@@ -259,7 +259,9 @@ export const PlayerCore = memo(function PlayerCore({
Unable To Load Video
</div>
<p className="text-xs text-muted-foreground">
The Bunny stream is unavailable right now. Please refresh this page in a moment.
{activeProviderId === 'r2'
? 'This video file could not be loaded. Try refreshing the page or re-uploading the version.'
: 'The Bunny stream is unavailable right now. Please refresh this page in a moment.'}
</p>
</div>
</div>
+4 -1
View File
@@ -185,10 +185,13 @@ export interface CommentActionsConfig {
videoId: string;
}
export type DirectUploadProvider = 'bunny' | 'r2';
export interface VersionActionsConfig {
projectId?: string;
videoId: string;
bunnyUploadsEnabled?: boolean;
directUploadsEnabled?: boolean;
directUploadProvider?: DirectUploadProvider;
}
export interface VideoPageHeaderActions {
@@ -28,7 +28,7 @@ import type { VideoSource } from '@/lib/video-providers';
interface VersionActionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bunnyUploadsEnabled: boolean;
directUploadsEnabled: boolean;
newVersionMode: 'url' | 'file';
onNewVersionModeChange: (mode: 'url' | 'file') => void;
newVersionUrl: string;
@@ -49,7 +49,7 @@ interface VersionActionsDialogProps {
export const VersionActionsDialog = memo(function VersionActionsDialog({
open,
onOpenChange,
bunnyUploadsEnabled,
directUploadsEnabled,
newVersionMode,
onNewVersionModeChange,
newVersionUrl,
@@ -88,10 +88,10 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
className="mb-2"
>
<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">Link URL</TabsTrigger>
{bunnyUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
{directUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
</TabsList>
</Tabs>
+3 -3
View File
@@ -57,7 +57,7 @@ interface VideoPageHeaderProps {
onDownload: (preference?: BunnyDownloadPreference) => void;
projectId?: string;
videoId: string;
bunnyUploadsEnabled: boolean;
directUploadsEnabled: boolean;
showVersionDialog: boolean;
setShowVersionDialog: (open: boolean) => void;
newVersionMode: 'url' | 'file';
@@ -105,7 +105,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
onDownload,
projectId,
videoId,
bunnyUploadsEnabled,
directUploadsEnabled,
showVersionDialog,
setShowVersionDialog,
newVersionMode,
@@ -261,7 +261,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
<VersionActionsDialog
open={showVersionDialog}
onOpenChange={setShowVersionDialog}
bunnyUploadsEnabled={bunnyUploadsEnabled}
directUploadsEnabled={directUploadsEnabled}
newVersionMode={newVersionMode}
onNewVersionModeChange={setNewVersionMode}
newVersionUrl={newVersionUrl}
+181
View File
@@ -0,0 +1,181 @@
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
export type R2VideoInitResponse = {
presignedPutUrl: string;
objectKey: string;
proxyUrl: string;
uploadToken: string;
reservationId: string | null;
contentType: string;
thumbnailPresignedPutUrl: string;
thumbnailObjectKey: string;
thumbnailProxyUrl: string;
};
export type R2VideoUploadResult = R2VideoInitResponse & {
duration: number | null;
thumbnailUrl: string | null;
};
type UploadProgressHandler = (progress: number) => void;
function uploadBytesWithProgress(
url: string,
body: Blob | File,
contentType: string,
onProgress?: UploadProgressHandler
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', contentType);
xhr.upload.onprogress = (event) => {
if (!onProgress || !event.lengthComputable) return;
onProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
return;
}
reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => {
reject(
new Error(
'Network error during upload. If you use direct S3/R2 uploads, configure bucket CORS to allow PUT from this site origin.'
)
);
};
xhr.onabort = () => reject(new Error('Upload aborted'));
xhr.send(body);
});
}
async function readVideoDuration(file: File): Promise<number | null> {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
const cleanup = () => {
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(objectUrl);
};
video.onloadedmetadata = () => {
const duration =
Number.isFinite(video.duration) && video.duration > 0 ? Math.round(video.duration) : null;
cleanup();
resolve(duration);
};
video.onerror = () => {
cleanup();
resolve(null);
};
video.src = objectUrl;
});
}
export async function initR2VideoUpload(
projectId: string,
file: File
): Promise<R2VideoInitResponse> {
const initRes = await fetch(`/api/projects/${projectId}/videos/r2-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
sizeBytes: file.size,
}),
});
const initPayload = (await initRes.json().catch(() => null)) as {
data?: R2VideoInitResponse;
error?: string;
} | null;
if (!initRes.ok || !initPayload?.data) {
throw new Error(initPayload?.error || 'Failed to initialize video upload');
}
return initPayload.data;
}
export async function cleanupPendingR2VideoUpload(
projectId: string,
input: {
objectKey: string;
uploadToken: string;
reservationId: string | null;
thumbnailObjectKey?: string | null;
},
keepalive = false
): Promise<void> {
try {
await fetch(`/api/projects/${projectId}/videos/r2-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
objectKey: input.objectKey,
uploadToken: input.uploadToken,
reservationId: input.reservationId,
thumbnailObjectKey: input.thumbnailObjectKey ?? undefined,
}),
keepalive,
});
} catch (error) {
console.error('Failed to cleanup pending R2 video upload:', error);
}
}
export async function uploadVideoToR2(
projectId: string,
file: File,
options?: { onProgress?: UploadProgressHandler }
): Promise<R2VideoUploadResult> {
const init = await initR2VideoUpload(projectId, file);
const cleanupInput = {
objectKey: init.objectKey,
uploadToken: init.uploadToken,
reservationId: init.reservationId,
thumbnailObjectKey: init.thumbnailObjectKey,
};
try {
await uploadBytesWithProgress(
init.presignedPutUrl,
file,
init.contentType,
options?.onProgress
);
} catch (error) {
await cleanupPendingR2VideoUpload(projectId, cleanupInput);
throw error;
}
const [duration, thumbnailBlob] = await Promise.all([
readVideoDuration(file),
captureVideoThumbnail(file),
]);
let thumbnailUrl: string | null = null;
if (thumbnailBlob) {
try {
await uploadBytesWithProgress(init.thumbnailPresignedPutUrl, thumbnailBlob, 'image/jpeg');
thumbnailUrl = init.thumbnailProxyUrl;
} catch (error) {
console.warn('Failed to upload video thumbnail:', error);
}
}
return { ...init, duration, thumbnailUrl };
}
+63
View File
@@ -0,0 +1,63 @@
const THUMBNAIL_MAX_WIDTH = 640;
const DEFAULT_SEEK_SECONDS = 1;
export async function captureVideoThumbnail(
file: File,
seekSeconds = DEFAULT_SEEK_SECONDS
): Promise<Blob | null> {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
let settled = false;
const finish = (blob: Blob | null) => {
if (settled) return;
settled = true;
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(objectUrl);
resolve(blob);
};
video.onloadedmetadata = () => {
const duration = Number.isFinite(video.duration) ? video.duration : 0;
const target =
duration > 0 ? Math.min(Math.max(seekSeconds, 0), Math.max(0, duration - 0.1)) : 0;
video.currentTime = target;
};
video.onseeked = () => {
try {
const width = video.videoWidth;
const height = video.videoHeight;
if (width <= 0 || height <= 0) {
finish(null);
return;
}
const scale = Math.min(1, THUMBNAIL_MAX_WIDTH / width);
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const context = canvas.getContext('2d');
if (!context) {
finish(null);
return;
}
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => finish(blob), 'image/jpeg', 0.85);
} catch {
finish(null);
}
};
video.onerror = () => finish(null);
video.src = objectUrl;
});
}
+58
View File
@@ -1,3 +1,5 @@
import { logError } from '@/lib/logger';
function readBooleanEnv(name: string, defaultValue: boolean): boolean {
const value = process.env[name];
if (!value) return defaultValue;
@@ -9,6 +11,20 @@ function readBooleanEnv(name: string, defaultValue: boolean): boolean {
return defaultValue;
}
let warnedAboutConflictingUploadFlags = false;
function warnIfConflictingDirectUploadFlags(): void {
if (warnedAboutConflictingUploadFlags) return;
if (!isS3VideoUploadsFeatureEnabled() || !isBunnyUploadsFeatureEnabled()) return;
if (!hasR2Config() || !hasBunnyUploadsConfig()) return;
warnedAboutConflictingUploadFlags = true;
logError(
'OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_ENABLE_BUNNY_UPLOADS are both enabled with valid config. S3 video uploads take precedence; disable Bunny uploads for self-hosted deployments.',
new Error('Conflicting direct upload feature flags')
);
}
export function isStripeFeatureEnabled() {
return readBooleanEnv('OPENFRAME_ENABLE_STRIPE', true);
}
@@ -32,10 +48,52 @@ export function hasBunnyUploadsConfig() {
);
}
export function isS3VideoUploadsFeatureEnabled() {
return readBooleanEnv('OPENFRAME_ENABLE_S3_VIDEO_UPLOADS', false);
}
export function hasR2Config() {
return Boolean(
process.env.R2_ACCESS_KEY_ID &&
process.env.R2_SECRET_ACCESS_KEY &&
process.env.R2_BUCKET_NAME &&
(process.env.R2_ENDPOINT || process.env.R2_ACCOUNT_ID)
);
}
export function isS3VideoUploadsEnabled() {
warnIfConflictingDirectUploadFlags();
return isS3VideoUploadsFeatureEnabled() && hasR2Config();
}
export function isBunnyUploadsEnabled() {
if (isS3VideoUploadsEnabled()) {
return false;
}
return isBunnyUploadsFeatureEnabled() && hasBunnyUploadsConfig();
}
export function isDirectFileUploadEnabled() {
return isS3VideoUploadsEnabled() || isBunnyUploadsEnabled();
}
export function getMaxVideoUploadBytes(): bigint {
const raw = process.env.OPENFRAME_MAX_VIDEO_UPLOAD_BYTES?.trim();
if (!raw) {
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
try {
const parsed = BigInt(raw);
if (parsed <= BigInt(0)) {
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
return parsed;
} catch {
return BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024);
}
}
export function isInviteCodeRequired() {
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Converts values for JSON responses (e.g. Prisma BigInt fields).
*/
export function toJsonSafe<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? val.toString() : val))
) as T;
}
+30 -4
View File
@@ -2,6 +2,7 @@ import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db';
import { runWithConcurrency } from '@/lib/async-pool';
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
/** The path prefix for images served by the upload API. */
@@ -32,7 +33,8 @@ export function mediaUrlToKey(url: string): string | null {
const filename = url.slice(IMAGE_PATH_PREFIX.length);
return filename ? `images/${filename}` : null;
}
return null;
return videoProxyPathToObjectKey(url);
}
/**
@@ -80,7 +82,7 @@ export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
const [comments, assets, versions] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
@@ -95,6 +97,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
},
select: { sourceUrl: true },
}),
db.videoVersion.findMany({
where: { videoParentId: videoId, providerId: 'r2' },
select: { originalUrl: true, thumbnailUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -104,6 +110,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
versions.forEach((version) => {
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
return urls;
}
@@ -111,7 +121,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
const [comments, assets, versions] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
@@ -126,6 +136,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
},
select: { sourceUrl: true },
}),
db.videoVersion.findMany({
where: { providerId: 'r2', video: { projectId } },
select: { originalUrl: true, thumbnailUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -135,6 +149,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
versions.forEach((version) => {
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
return urls;
}
@@ -142,7 +160,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const [comments, assets] = await Promise.all([
const [comments, assets, versions] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
@@ -157,6 +175,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
},
select: { sourceUrl: true },
}),
db.videoVersion.findMany({
where: { providerId: 'r2', video: { project: { workspaceId } } },
select: { originalUrl: true, thumbnailUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -166,6 +188,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
versions.forEach((version) => {
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
return urls;
}
+7 -1
View File
@@ -168,13 +168,19 @@ export async function proxyR2MediaObject({
}
const headers = new Headers();
setIfPresent(headers, 'Content-Type', objectResponse.ContentType || fallbackContentType);
const resolvedContentType =
objectResponse.ContentType && objectResponse.ContentType !== 'application/octet-stream'
? objectResponse.ContentType
: fallbackContentType;
setIfPresent(headers, 'Content-Type', resolvedContentType);
setIfPresent(headers, 'Content-Length', objectResponse.ContentLength);
setIfPresent(headers, 'Content-Range', objectResponse.ContentRange);
setIfPresent(headers, 'ETag', objectResponse.ETag);
setIfPresent(headers, 'Last-Modified', objectResponse.LastModified?.toUTCString());
setIfPresent(headers, 'Accept-Ranges', objectResponse.AcceptRanges || 'bytes');
headers.set('Cache-Control', cacheControl);
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Content-Disposition', 'inline');
if (extraHeaders) {
for (const [name, value] of Object.entries(extraHeaders)) {
+45
View File
@@ -0,0 +1,45 @@
import { db } from '@/lib/db';
export type CreateR2UploadSessionInput = {
userId: string;
projectId: string;
billedUserId: string;
objectKey: string;
thumbnailObjectKey: string;
declaredSizeBytes: bigint;
contentType: string;
reservationId: string | null;
uploadJti: string;
expiresAt: Date;
};
export async function createR2UploadSession(input: CreateR2UploadSessionInput) {
return db.videoUploadSession.create({
data: {
userId: input.userId,
projectId: input.projectId,
billedUserId: input.billedUserId,
objectKey: input.objectKey,
thumbnailObjectKey: input.thumbnailObjectKey,
declaredSizeBytes: input.declaredSizeBytes,
contentType: input.contentType,
reservationId: input.reservationId,
uploadJti: input.uploadJti,
expiresAt: input.expiresAt,
},
});
}
export async function cancelR2UploadSession(sessionId: string) {
return db.videoUploadSession.updateMany({
where: {
id: sessionId,
status: 'INITIATED',
expiresAt: { gt: new Date() },
},
data: {
status: 'CANCELLED',
consumedAt: new Date(),
},
});
}
+129
View File
@@ -0,0 +1,129 @@
import crypto from 'crypto';
const R2_UPLOAD_TOKEN_TYPE = 'r2-upload';
const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60;
interface R2UploadTokenPayload {
typ: typeof R2_UPLOAD_TOKEN_TYPE;
uid: string;
pid: string;
key: string;
sid: string;
jti: string;
tkey: string;
iat: number;
exp: number;
}
export interface R2UploadTokenSubject {
userId: string;
projectId: string;
objectKey: string;
sessionId?: string;
tokenId?: string;
thumbnailObjectKey?: string;
}
function getR2UploadTokenSecret(): string {
const secret = process.env.R2_UPLOAD_TOKEN_SECRET || process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Missing R2_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.');
}
return secret;
}
function signPayload(payload: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
}
function isValidPayload(value: unknown): value is R2UploadTokenPayload {
if (!value || typeof value !== 'object') return false;
const payload = value as Partial<R2UploadTokenPayload>;
return (
payload.typ === R2_UPLOAD_TOKEN_TYPE &&
typeof payload.uid === 'string' &&
typeof payload.pid === 'string' &&
typeof payload.key === 'string' &&
typeof payload.sid === 'string' &&
typeof payload.jti === 'string' &&
typeof payload.tkey === 'string' &&
typeof payload.iat === 'number' &&
Number.isFinite(payload.iat) &&
typeof payload.exp === 'number' &&
Number.isFinite(payload.exp)
);
}
export function createR2UploadToken(
subject: R2UploadTokenSubject & {
sessionId: string;
tokenId: string;
thumbnailObjectKey: string;
},
ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS
): string {
const now = Math.floor(Date.now() / 1000);
const payload: R2UploadTokenPayload = {
typ: R2_UPLOAD_TOKEN_TYPE,
uid: subject.userId,
pid: subject.projectId,
key: subject.objectKey,
sid: subject.sessionId,
jti: subject.tokenId,
tkey: subject.thumbnailObjectKey,
iat: now,
exp: now + ttlSeconds,
};
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
const signature = signPayload(encodedPayload, getR2UploadTokenSecret());
return `${encodedPayload}.${signature}`;
}
export function verifyR2UploadToken(token: string, subject: R2UploadTokenSubject): boolean {
const payload = parseR2UploadToken(token);
if (!payload) return false;
if (
payload.uid !== subject.userId ||
payload.pid !== subject.projectId ||
payload.key !== subject.objectKey
) {
return false;
}
if (subject.sessionId && payload.sid !== subject.sessionId) return false;
if (subject.tokenId && payload.jti !== subject.tokenId) return false;
if (subject.thumbnailObjectKey && payload.tkey !== subject.thumbnailObjectKey) return false;
return true;
}
export function parseR2UploadToken(token: string): R2UploadTokenPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [encodedPayload, providedSignature] = parts;
if (!encodedPayload || !providedSignature) return null;
const expectedSignature = signPayload(encodedPayload, getR2UploadTokenSecret());
const providedBuffer = Buffer.from(providedSignature, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
if (providedBuffer.length !== expectedBuffer.length) return null;
if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) return null;
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf8');
const payloadUnknown: unknown = JSON.parse(payloadJson);
if (!isValidPayload(payloadUnknown)) return null;
const payload = payloadUnknown;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
} catch {
return null;
}
}
+181
View File
@@ -0,0 +1,181 @@
import { db } from '@/lib/db';
import { getMaxVideoUploadBytes } from '@/lib/feature-flags';
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
import { parseR2UploadToken, verifyR2UploadToken } from '@/lib/r2-upload-token';
import {
objectKeyToVideoProxyPath,
videoProxyPathToObjectKey,
} from '@/lib/video-upload-validation';
export type R2VideoFinalizeInput = {
userId: string;
projectId: string;
videoUrl: string;
objectKey: string;
uploadToken: string;
};
export type R2VideoFinalizeResult =
| {
ok: true;
sizeBytes: bigint;
proxyUrl: string;
objectKey: string;
sessionId: string;
reservationId: string | null;
billedUserId: string;
thumbnailObjectKey: string;
thumbnailProxyUrl: string;
}
| { ok: false; error: string; status: 400 | 403 };
function hasKnownVideoMagicBytes(bytes: Uint8Array): boolean {
if (bytes.length >= 12) {
const box = String.fromCharCode(bytes[4] ?? 0, bytes[5] ?? 0, bytes[6] ?? 0, bytes[7] ?? 0);
if (box === 'ftyp') return true;
}
if (
bytes.length >= 4 &&
bytes[0] === 0x1a &&
bytes[1] === 0x45 &&
bytes[2] === 0xdf &&
bytes[3] === 0xa3
) {
return true;
}
if (
bytes.length >= 4 &&
bytes[0] === 0x4f &&
bytes[1] === 0x67 &&
bytes[2] === 0x67 &&
bytes[3] === 0x53
) {
return true;
}
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x41 &&
bytes[9] === 0x56 &&
bytes[10] === 0x49 &&
bytes[11] === 0x20
) {
return true;
}
return false;
}
export async function finalizeR2VideoUpload(
input: R2VideoFinalizeInput
): Promise<R2VideoFinalizeResult> {
const { userId, projectId, videoUrl, objectKey, uploadToken } = input;
if (!objectKey || !uploadToken) {
return { ok: false, error: 'R2 uploads must include objectKey and uploadToken', status: 400 };
}
const expectedProxyUrl = objectKeyToVideoProxyPath(objectKey);
if (!expectedProxyUrl) {
return { ok: false, error: 'Invalid object key', status: 400 };
}
if (videoUrl !== expectedProxyUrl) {
return { ok: false, error: 'Video URL does not match the uploaded object', status: 400 };
}
const keyFromUrl = videoProxyPathToObjectKey(videoUrl);
if (!keyFromUrl || keyFromUrl !== objectKey) {
return { ok: false, error: 'Video URL does not match the uploaded object', status: 400 };
}
const tokenPayload = parseR2UploadToken(uploadToken);
if (!tokenPayload) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
userId,
projectId,
objectKey,
sessionId: tokenPayload.sid,
tokenId: tokenPayload.jti,
});
if (!isValidUploadToken) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const uploadSession = await db.videoUploadSession.findFirst({
where: {
id: tokenPayload.sid,
uploadJti: tokenPayload.jti,
status: 'INITIATED',
userId,
projectId,
objectKey,
thumbnailObjectKey: tokenPayload.tkey,
expiresAt: { gt: new Date() },
},
select: {
id: true,
billedUserId: true,
reservationId: true,
declaredSizeBytes: true,
thumbnailObjectKey: true,
},
});
if (!uploadSession) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const thumbnailFilename = uploadSession.thumbnailObjectKey.startsWith('images/')
? uploadSession.thumbnailObjectKey.slice('images/'.length)
: '';
if (!thumbnailFilename) {
return { ok: false, error: 'Invalid upload token', status: 403 };
}
const cancelPendingUpload = async (error: string): Promise<R2VideoFinalizeResult> => {
await db.videoUploadSession.updateMany({
where: { id: uploadSession.id, status: 'INITIATED' },
data: { status: 'CANCELLED', consumedAt: new Date() },
});
await Promise.all([
deleteVideoObject(objectKey).catch(() => undefined),
deleteR2Object(uploadSession.thumbnailObjectKey).catch(() => undefined),
]);
return { ok: false, error, status: 400 };
};
const head = await headVideoObject(objectKey);
if (!head || head.contentLength <= BigInt(0)) {
return cancelPendingUpload('Uploaded video was not found in storage');
}
if (head.contentLength > getMaxVideoUploadBytes()) {
return cancelPendingUpload('Uploaded video exceeds the maximum allowed upload size');
}
if (head.contentLength > uploadSession.declaredSizeBytes) {
return cancelPendingUpload('Uploaded video size does not match upload request');
}
const headerBytes = await readVideoObjectBytes(objectKey, 64);
if (!headerBytes || !hasKnownVideoMagicBytes(headerBytes)) {
return cancelPendingUpload('Uploaded file is not a valid video');
}
return {
ok: true,
sizeBytes: head.contentLength,
proxyUrl: expectedProxyUrl,
objectKey,
sessionId: uploadSession.id,
reservationId: uploadSession.reservationId,
billedUserId: uploadSession.billedUserId,
thumbnailObjectKey: uploadSession.thumbnailObjectKey,
thumbnailProxyUrl: `/api/upload/image/${thumbnailFilename}`,
};
}
+269
View File
@@ -1,18 +1,29 @@
import {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
GetBucketCorsCommand,
HeadBucketCommand,
HeadObjectCommand,
PutBucketCorsCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { VIDEO_OBJECT_KEY_PREFIX } from '@/lib/video-upload-validation';
const IMAGE_OBJECT_KEY_PREFIX = 'images/';
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID;
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY;
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? '';
const R2_ENDPOINT = process.env.R2_ENDPOINT;
const R2_PRESIGN_ENDPOINT = process.env.R2_PRESIGN_ENDPOINT;
const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL;
let cachedR2Client: S3Client | null = null;
let cachedR2PresignClient: S3Client | null = null;
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
@@ -38,6 +49,13 @@ function getR2Endpoint(): string {
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
}
function getR2PresignEndpoint(): string {
if (R2_PRESIGN_ENDPOINT) {
return trimTrailingSlashes(R2_PRESIGN_ENDPOINT);
}
return getR2Endpoint();
}
function getOrCreateR2Client(): S3Client {
if (cachedR2Client) {
return cachedR2Client;
@@ -47,6 +65,8 @@ function getOrCreateR2Client(): S3Client {
region: 'auto',
endpoint: getR2Endpoint(),
forcePathStyle: Boolean(R2_ENDPOINT),
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY),
@@ -56,6 +76,26 @@ function getOrCreateR2Client(): S3Client {
return cachedR2Client;
}
function getOrCreateR2PresignClient(): S3Client {
if (cachedR2PresignClient) {
return cachedR2PresignClient;
}
cachedR2PresignClient = new S3Client({
region: 'auto',
endpoint: getR2PresignEndpoint(),
forcePathStyle: Boolean(R2_PRESIGN_ENDPOINT || R2_ENDPOINT),
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: requireStorageValue('R2_ACCESS_KEY_ID', R2_ACCESS_KEY_ID),
secretAccessKey: requireStorageValue('R2_SECRET_ACCESS_KEY', R2_SECRET_ACCESS_KEY),
},
});
return cachedR2PresignClient;
}
export const r2Client = new Proxy({} as S3Client, {
get(_target, prop, receiver) {
if (prop === 'destroy') {
@@ -63,6 +103,9 @@ export const r2Client = new Proxy({} as S3Client, {
if (!cachedR2Client) return;
cachedR2Client.destroy();
cachedR2Client = null;
if (!cachedR2PresignClient) return;
cachedR2PresignClient.destroy();
cachedR2PresignClient = null;
};
}
@@ -127,4 +170,230 @@ export async function uploadAudio(
return getR2PublicObjectUrl(key);
}
const DEFAULT_PRESIGNED_PUT_TTL_SECONDS = 60 * 60;
export function getR2UploadCorsOrigins(extraOrigins: string[] = []): string[] {
const origins = new Set<string>();
for (const raw of [process.env.NEXTAUTH_URL, process.env.NEXT_PUBLIC_APP_URL, ...extraOrigins]) {
if (!raw?.trim()) continue;
try {
origins.add(new URL(trimTrailingSlashes(raw.trim())).origin);
} catch {
// Ignore invalid origin URLs.
}
}
if (process.env.NODE_ENV === 'development') {
origins.add('http://localhost:3000');
origins.add('http://127.0.0.1:3000');
}
return [...origins];
}
function corsRulesMatchOrigins(
existing:
| {
AllowedOrigins?: string[];
AllowedMethods?: string[];
}
| undefined,
requiredOrigins: string[]
): boolean {
if (!existing?.AllowedOrigins?.length || !existing.AllowedMethods?.length) {
return false;
}
const allowedOrigins = new Set(existing.AllowedOrigins);
const methods = new Set(existing.AllowedMethods.map((method) => method.toUpperCase()));
const hasRequiredOrigins = requiredOrigins.every((origin) => allowedOrigins.has(origin));
const hasPut = methods.has('PUT');
const hasGet = methods.has('GET') || methods.has('HEAD');
return hasRequiredOrigins && hasPut && hasGet;
}
export async function ensureR2UploadCors(extraOrigins: string[] = []): Promise<string[]> {
const allowedOrigins = getR2UploadCorsOrigins(extraOrigins);
if (allowedOrigins.length === 0) {
throw new Error(
'No origins configured for R2 upload CORS (set NEXTAUTH_URL or NEXT_PUBLIC_APP_URL)'
);
}
const managedRule = {
AllowedOrigins: allowedOrigins,
AllowedMethods: ['GET', 'PUT', 'HEAD'],
AllowedHeaders: ['*'],
ExposeHeaders: ['ETag'],
MaxAgeSeconds: 3600,
};
try {
const existing = await r2Client.send(
new GetBucketCorsCommand({
Bucket: R2_BUCKET_NAME,
})
);
const existingRules = existing.CORSRules ?? [];
if (existingRules.some((rule) => corsRulesMatchOrigins(rule, allowedOrigins))) {
return allowedOrigins;
}
await r2Client.send(
new PutBucketCorsCommand({
Bucket: R2_BUCKET_NAME,
CORSConfiguration: {
CORSRules: [...existingRules, managedRule],
},
})
);
return allowedOrigins;
} catch {
// No CORS config yet, or insufficient permissions to read — attempt to write.
}
await r2Client.send(
new PutBucketCorsCommand({
Bucket: R2_BUCKET_NAME,
CORSConfiguration: {
CORSRules: [managedRule],
},
})
);
return allowedOrigins;
}
export async function createPresignedVideoPutUrl(
key: string,
contentType: string,
contentLength: bigint,
expiresInSeconds = DEFAULT_PRESIGNED_PUT_TTL_SECONDS
): Promise<string> {
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
throw new Error('Invalid video object key');
}
if (contentLength <= BigInt(0) || contentLength > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('Invalid video content length');
}
const command = new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
ContentType: contentType,
ContentLength: Number(contentLength),
});
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
}
export async function createPresignedImagePutUrl(
key: string,
contentType: string,
expiresInSeconds = DEFAULT_PRESIGNED_PUT_TTL_SECONDS
): Promise<string> {
if (!key.startsWith(IMAGE_OBJECT_KEY_PREFIX)) {
throw new Error('Invalid image object key');
}
const command = new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
ContentType: contentType,
});
return getSignedUrl(getOrCreateR2PresignClient(), command, { expiresIn: expiresInSeconds });
}
export async function headVideoObject(key: string): Promise<{
contentLength: bigint;
contentType: string | undefined;
} | null> {
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
return null;
}
try {
const result = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
const contentLength =
typeof result.ContentLength === 'number' && result.ContentLength >= 0
? BigInt(result.ContentLength)
: BigInt(0);
return {
contentLength,
contentType: result.ContentType,
};
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
?.httpStatusCode;
if (statusCode === 404) return null;
throw error;
}
}
export async function readVideoObjectBytes(
key: string,
byteLength: number
): Promise<Uint8Array | null> {
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) || byteLength <= 0) {
return null;
}
const rangeEnd = Math.max(0, byteLength - 1);
try {
const result = await r2Client.send(
new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Range: `bytes=0-${rangeEnd}`,
})
);
if (!result.Body) return null;
const body = result.Body as { transformToByteArray?: () => Promise<Uint8Array> };
if (typeof body.transformToByteArray !== 'function') return null;
return await body.transformToByteArray();
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
?.httpStatusCode;
if (statusCode === 404 || statusCode === 416) return null;
throw error;
}
}
function assertAllowedObjectKey(key: string): void {
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX) && !key.startsWith(IMAGE_OBJECT_KEY_PREFIX)) {
throw new Error('Invalid object key');
}
}
export async function deleteVideoObject(key: string): Promise<void> {
if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) {
throw new Error('Invalid video object key');
}
await deleteR2Object(key);
}
export async function deleteR2Object(key: string): Promise<void> {
assertAllowedObjectKey(key);
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
}
export { R2_BUCKET_NAME };
+37 -9
View File
@@ -20,13 +20,22 @@ class QuotaExceededError extends Error {}
* every upload.
*/
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
const [r2Rows, bunnyByUser, reservationRows] = await Promise.all([
const [r2AssetRows, r2VideoRows, bunnyByUser, reservationRows] = await Promise.all([
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets
WHERE "billedUserId" = ${userId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
`,
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
FROM video_versions vv
INNER JOIN videos v ON v.id = vv."videoParentId"
INNER JOIN projects p ON p.id = v."projectId"
INNER JOIN workspaces w ON w.id = p."workspaceId"
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`,
getCachedUserBunnyStorage(),
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
@@ -36,11 +45,12 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
`,
]);
const r2Bytes = r2Rows[0]?.total ?? BigInt(0);
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0);
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
return r2Bytes + bunnyBytes + reservedBytes;
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
}
/**
@@ -102,13 +112,14 @@ export async function enforceStorageQuota(
*/
export async function reserveStorageQuota(
userId: string,
incomingSizeBytes: bigint
incomingSizeBytes: bigint,
reservationTtlMs: number = RESERVATION_TTL_MS
): Promise<{ reservationId: string | null } | { error: NextResponse }> {
if (!isStripeFeatureEnabled()) {
return { reservationId: null };
}
const expiresAt = new Date(Date.now() + RESERVATION_TTL_MS);
const expiresAt = new Date(Date.now() + reservationTtlMs);
// Fetch Bunny storage BEFORE entering the transaction to avoid holding the
// advisory lock during a potentially slow/failing HTTP call on cache miss.
@@ -128,13 +139,22 @@ export async function reserveStorageQuota(
`;
// Read committed R2 storage under the lock
const [r2Row] = await tx.$queryRaw<[{ total: bigint }]>`
const [r2AssetRow] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets
WHERE "billedUserId" = ${userId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
`;
const r2Bytes = r2Row?.total ?? BigInt(0);
const [r2VideoRow] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
FROM video_versions vv
INNER JOIN videos v ON v.id = vv."videoParentId"
INNER JOIN projects p ON p.id = v."projectId"
INNER JOIN workspaces w ON w.id = p."workspaceId"
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`;
const r2Bytes = (r2AssetRow?.total ?? BigInt(0)) + (r2VideoRow?.total ?? BigInt(0));
// Read active (non-expired) reservations under the same lock
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
@@ -171,7 +191,15 @@ export async function reserveStorageQuota(
* Deletes an upload reservation created by `reserveStorageQuota`.
* Safe to call with `null` (no-op) for flows where billing is disabled.
*/
export async function releaseStorageReservation(reservationId: string | null): Promise<void> {
export async function releaseStorageReservation(
reservationId: string | null,
billedUserId?: string | null
): Promise<void> {
if (!reservationId) return;
await db.uploadReservation.deleteMany({ where: { id: reservationId } });
await db.uploadReservation.deleteMany({
where: {
id: reservationId,
...(billedUserId ? { billedUserId } : {}),
},
});
}
+29
View File
@@ -91,3 +91,32 @@ export function validateOptionalUrl(
return validateUrl(urlString, fieldName);
}
const SAFE_APP_RELATIVE_PATH =
/^\/(?:api\/upload\/(?:image|audio|video)\/[0-9a-f-]{36}\.[a-z0-9]+|placeholder-video-thumbnail\.png)$/i;
export function isSafeAppRelativePath(path: string): boolean {
if (!path.startsWith('/') || path.includes('..')) {
return false;
}
return SAFE_APP_RELATIVE_PATH.test(path);
}
/**
* Accepts optional absolute http(s) URLs or safe same-origin app paths (upload proxy, placeholders).
*/
export function validateOptionalUrlOrAppPath(
urlString: string | null | undefined,
fieldName: string = 'URL'
): string | null {
if (!urlString) {
return null;
}
if (isSafeAppRelativePath(urlString)) {
return null;
}
return validateOptionalUrl(urlString, fieldName);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { youtubeProvider } from './youtube';
import { directProvider } from './direct';
import { bunnyProvider } from './bunny';
import { r2Provider } from './r2';
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
import { logError } from '@/lib/logger';
@@ -10,7 +11,7 @@ import { logError } from '@/lib/logger';
export * from './types';
// Registry of all available providers
const providers: VideoProvider[] = [youtubeProvider, directProvider, bunnyProvider];
const providers: VideoProvider[] = [youtubeProvider, directProvider, bunnyProvider, r2Provider];
// Provider lookup map for quick access
const providerMap = new Map<string, VideoProvider>(providers.map((p) => [p.id, p]));
+42
View File
@@ -0,0 +1,42 @@
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
const R2_VIDEO_PROXY_PATH = /^\/api\/upload\/video\/[0-9a-f-]{36}\.[a-z0-9]+$/i;
export const r2Provider: VideoProvider = {
id: 'r2',
name: 'Self-hosted',
icon: 'Upload',
canHandle(url: string): boolean {
return R2_VIDEO_PROXY_PATH.test(url);
},
extractVideoId(url: string): string | null {
if (this.canHandle(url)) {
return url;
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
const params = new URLSearchParams();
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
const queryString = params.toString();
return `${videoId}${queryString ? `?${queryString}` : ''}`;
},
getThumbnailUrl(_videoId: string): string {
void _videoId;
return '/placeholder-video-thumbnail.png';
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
const filename = videoId.split('/').pop() || 'Video';
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
return {
title: nameWithoutExt,
thumbnailUrl: this.getThumbnailUrl(videoId),
};
},
};
+1 -1
View File
@@ -38,7 +38,7 @@ export interface EmbedOptions {
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
// Supported provider types - extend as we add more
export type VideoProviderType = 'youtube' | 'direct' | 'bunny';
export type VideoProviderType = 'youtube' | 'direct' | 'bunny' | 'r2';
// Video source stored in database
export interface VideoSource {
+87
View File
@@ -0,0 +1,87 @@
const VIDEO_MIME_TO_EXT: Record<string, string> = {
'video/mp4': 'mp4',
'video/webm': 'webm',
'video/ogg': 'ogg',
'video/quicktime': 'mov',
'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi',
};
const EXT_TO_MIME: 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',
};
const ALLOWED_VIDEO_EXTENSIONS = new Set(Object.keys(EXT_TO_MIME));
export function normalizeVideoMime(mime: string | undefined): string | null {
if (!mime) return null;
const normalized = mime.split(';')[0]?.trim().toLowerCase() ?? '';
if (!normalized.startsWith('video/')) return null;
return normalized;
}
export function getVideoExtensionFromMime(mime: string): string | null {
return VIDEO_MIME_TO_EXT[mime] ?? null;
}
export function getVideoExtensionFromFileName(fileName: string): string | null {
const ext = fileName.split('.').pop()?.toLowerCase();
if (!ext || !ALLOWED_VIDEO_EXTENSIONS.has(ext)) return null;
return ext;
}
export function resolveVideoContentType(fileName: string, mime: string | undefined): string | null {
const normalizedMime = normalizeVideoMime(mime);
if (normalizedMime) {
const extFromMime = getVideoExtensionFromMime(normalizedMime);
const extFromName = getVideoExtensionFromFileName(fileName);
if (extFromMime && extFromName && extFromMime !== extFromName) {
return EXT_TO_MIME[extFromName] ?? normalizedMime;
}
return normalizedMime;
}
const ext = getVideoExtensionFromFileName(fileName);
if (!ext) return null;
return EXT_TO_MIME[ext] ?? null;
}
export function isAllowedVideoFile(fileName: string, mime: string | undefined): boolean {
return resolveVideoContentType(fileName, mime) !== null;
}
export const VIDEO_OBJECT_KEY_PREFIX = 'videos/';
const SAFE_VIDEO_BASENAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export function buildVideoObjectKey(filename: string): string {
return `${VIDEO_OBJECT_KEY_PREFIX}${filename}`;
}
export function videoProxyPathFromFilename(filename: string): string {
return `/api/upload/video/${filename}`;
}
export function videoProxyPathToObjectKey(proxyPath: string): string | null {
const prefix = '/api/upload/video/';
if (!proxyPath.startsWith(prefix)) return null;
const filename = proxyPath.slice(prefix.length);
if (!SAFE_VIDEO_BASENAME.test(filename)) return null;
return buildVideoObjectKey(filename);
}
export function objectKeyToVideoProxyPath(objectKey: string): string | null {
if (!objectKey.startsWith(VIDEO_OBJECT_KEY_PREFIX)) return null;
const filename = objectKey.slice(VIDEO_OBJECT_KEY_PREFIX.length);
if (!SAFE_VIDEO_BASENAME.test(filename)) return null;
return videoProxyPathFromFilename(filename);
}
export { SAFE_VIDEO_BASENAME };
+36
View File
@@ -15,6 +15,41 @@ function resolveBunnyCdnHostname(): string | null {
const bunnyCdnHostname = resolveBunnyCdnHostname();
const isDev = process.env.NODE_ENV === 'development';
function resolveR2ConnectOrigins(): string[] {
const origins = new Set<string>();
for (const raw of [
process.env.R2_ENDPOINT?.trim(),
process.env.R2_PRESIGN_ENDPOINT?.trim(),
process.env.R2_PUBLIC_BASE_URL?.trim(),
]) {
if (!raw) continue;
try {
origins.add(new URL(raw).origin);
} catch {
// Ignore invalid custom endpoints in CSP generation.
}
}
const accountId = process.env.R2_ACCOUNT_ID?.trim();
const bucket = process.env.R2_BUCKET_NAME?.trim();
if (accountId) {
origins.add(`https://${accountId}.r2.cloudflarestorage.com`);
if (bucket) {
origins.add(`https://${bucket}.${accountId}.r2.cloudflarestorage.com`);
}
origins.add('https://*.r2.cloudflarestorage.com');
}
// Docker/MinIO self-hosted defaults. Keep unconditional because CSP is
// compiled at build time and build env may not include runtime self-hosted
// variables.
origins.add('http://localhost:9000');
origins.add('http://127.0.0.1:9000');
return [...origins];
}
// Build Content-Security-Policy from resolved config
const cdnOrigin = bunnyCdnHostname ? `https://${bunnyCdnHostname}` : '';
@@ -23,6 +58,7 @@ const connectSrcParts = [
'https://video.bunnycdn.com',
'https://www.youtube.com',
cdnOrigin,
...resolveR2ConnectOrigins(),
// Allow Next.js HMR websocket in development
...(isDev ? ['ws://localhost:* wss://localhost:*'] : []),
].filter(Boolean);
+3 -1
View File
@@ -21,6 +21,7 @@
"db:seed": "prisma db seed",
"db:setup": "bun run db:generate && bun run db:migrate",
"self-host:bootstrap": "bun run scripts/self-host-bootstrap.ts",
"r2:configure-cors": "bun run scripts/configure-r2-cors.ts",
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
@@ -28,7 +29,8 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.1029.0",
"@aws-sdk/client-s3": "3.1054.0",
"@aws-sdk/s3-request-presigner": "3.1054.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
@@ -0,0 +1 @@
ALTER TABLE "video_versions" ADD COLUMN "size_bytes" BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,41 @@
-- CreateEnum
CREATE TYPE "UploadSessionStatus" AS ENUM ('INITIATED', 'FINALIZED', 'CANCELLED', 'EXPIRED');
-- CreateTable
CREATE TABLE "video_upload_sessions" (
"id" TEXT NOT NULL,
"upload_jti" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"billed_user_id" TEXT NOT NULL,
"object_key" TEXT NOT NULL,
"thumbnail_object_key" TEXT NOT NULL,
"declared_size_bytes" BIGINT NOT NULL,
"content_type" TEXT NOT NULL,
"reservation_id" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"status" "UploadSessionStatus" NOT NULL DEFAULT 'INITIATED',
"consumed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "video_upload_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "video_upload_sessions_upload_jti_key" ON "video_upload_sessions"("upload_jti");
-- CreateIndex
CREATE UNIQUE INDEX "video_upload_sessions_object_key_key" ON "video_upload_sessions"("object_key");
-- CreateIndex
CREATE INDEX "video_upload_sessions_projectId_status_idx" ON "video_upload_sessions"("projectId", "status");
-- CreateIndex
CREATE INDEX "video_upload_sessions_userId_status_idx" ON "video_upload_sessions"("userId", "status");
-- CreateIndex
CREATE INDEX "video_upload_sessions_billed_user_id_idx" ON "video_upload_sessions"("billed_user_id");
-- CreateIndex
CREATE INDEX "video_upload_sessions_expires_at_idx" ON "video_upload_sessions"("expires_at");
@@ -0,0 +1,12 @@
-- Ensure a finalized R2 object key and proxy URL can only be claimed once.
CREATE UNIQUE INDEX "video_versions_r2_videoid_unique"
ON "video_versions" ("videoId")
WHERE "providerId" = 'r2';
CREATE UNIQUE INDEX "video_versions_r2_originalurl_unique"
ON "video_versions" ("originalUrl")
WHERE "providerId" = 'r2' AND "originalUrl" LIKE '/api/upload/video/%';
CREATE UNIQUE INDEX "video_versions_r2_thumbnail_unique"
ON "video_versions" ("thumbnailUrl")
WHERE "providerId" = 'r2' AND "thumbnailUrl" LIKE '/api/upload/image/%';
+32
View File
@@ -361,6 +361,7 @@ model VideoVersion {
title String?
thumbnailUrl String?
duration Int? // Duration in seconds
sizeBytes BigInt @default(0) @map("size_bytes") // R2-hosted video file size
// Status
isActive Boolean @default(true) // Currently displayed version
@@ -701,6 +702,37 @@ model UploadReservation {
@@map("upload_reservations")
}
enum UploadSessionStatus {
INITIATED
FINALIZED
CANCELLED
EXPIRED
}
model VideoUploadSession {
id String @id @default(cuid())
uploadJti String @unique @map("upload_jti")
userId String
projectId String
billedUserId String @map("billed_user_id")
objectKey String @unique @map("object_key")
thumbnailObjectKey String @map("thumbnail_object_key")
declaredSizeBytes BigInt @map("declared_size_bytes")
contentType String @map("content_type")
reservationId String? @map("reservation_id")
expiresAt DateTime @map("expires_at")
status UploadSessionStatus @default(INITIATED)
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([projectId, status])
@@index([userId, status])
@@index([billedUserId])
@@index([expiresAt])
@@map("video_upload_sessions")
}
// Rate limiting table (created as UNLOGGED via raw SQL migration)
// Defined here so `prisma db push` doesn't drop it
model RateLimit {
+20
View File
@@ -0,0 +1,20 @@
import 'dotenv/config';
import { ensureR2UploadCors, R2_BUCKET_NAME, r2Client } from '@/lib/r2';
import { logError } from '@/lib/logger';
async function main() {
const origins = await ensureR2UploadCors();
console.log(`Configured upload CORS on bucket "${R2_BUCKET_NAME}" for origins:`);
for (const origin of origins) {
console.log(` - ${origin}`);
}
}
main()
.catch((error) => {
logError('Failed to configure R2 upload CORS:', error);
process.exitCode = 1;
})
.finally(() => {
r2Client.destroy();
});
+11
View File
@@ -82,6 +82,16 @@ async function getPublicTables(client: Client) {
return result.rows.map((row) => row.table_name);
}
async function ensureRateLimitCleanupFunction(client: Client) {
await client.query(`
CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $$
BEGIN
DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
END;
$$ LANGUAGE plpgsql;
`);
}
async function main() {
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required');
@@ -132,6 +142,7 @@ async function main() {
await runPrisma(['migrate', 'resolve', '--applied', migrationName]);
}
await ensureRateLimitCleanupFunction(client);
console.log('Fresh database bootstrap complete');
return;
}
+41 -26
View File
@@ -10,7 +10,7 @@ import { logError } from '@/lib/logger';
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
const CHUNK_SIZE = 500;
const PREFIXES = ['images/', 'voice/'] as const;
const PREFIXES = ['images/', 'voice/', 'videos/'] as const;
type CleanupCandidate = {
key: string;
@@ -33,6 +33,10 @@ function keyToProxyUrl(key: string): string | null {
const filename = key.slice('voice/'.length);
return filename ? `/api/upload/audio/${filename}` : null;
}
if (key.startsWith('videos/')) {
const filename = key.slice('videos/'.length);
return filename ? `/api/upload/video/${filename}` : null;
}
return null;
}
@@ -93,31 +97,38 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
).userFeedbackScreenshot;
for (const group of chunk(urls, CHUNK_SIZE)) {
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
},
select: {
voiceUrl: true,
imageUrl: true,
},
}),
db.userFeedback.findMany({
where: { screenshotUrl: { in: group } },
select: { screenshotUrl: true },
}),
userFeedbackScreenshotDelegate
? userFeedbackScreenshotDelegate.findMany({
where: { url: { in: group } },
select: { url: true },
})
: Promise.resolve([] as Array<{ url: string }>),
db.videoAsset.findMany({
where: { sourceUrl: { in: group } },
select: { sourceUrl: true },
}),
]);
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows, versionRows] =
await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
},
select: {
voiceUrl: true,
imageUrl: true,
},
}),
db.userFeedback.findMany({
where: { screenshotUrl: { in: group } },
select: { screenshotUrl: true },
}),
userFeedbackScreenshotDelegate
? userFeedbackScreenshotDelegate.findMany({
where: { url: { in: group } },
select: { url: true },
})
: Promise.resolve([] as Array<{ url: string }>),
db.videoAsset.findMany({
where: { sourceUrl: { in: group } },
select: { sourceUrl: true },
}),
db.videoVersion.findMany({
where: {
OR: [{ originalUrl: { in: group } }, { thumbnailUrl: { in: group } }],
},
select: { originalUrl: true, thumbnailUrl: true },
}),
]);
for (const row of commentRows) {
if (row.voiceUrl) referenced.add(row.voiceUrl);
@@ -132,6 +143,10 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
for (const row of assetRows) {
if (row.sourceUrl) referenced.add(row.sourceUrl);
}
for (const row of versionRows) {
if (row.originalUrl) referenced.add(row.originalUrl);
if (row.thumbnailUrl) referenced.add(row.thumbnailUrl);
}
}
return referenced;
+23 -5
View File
@@ -1,5 +1,6 @@
import 'dotenv/config';
import { ensureR2BucketExists, R2_BUCKET_NAME } from '@/lib/r2';
import { ensureR2BucketExists, ensureR2UploadCors, R2_BUCKET_NAME, r2Client } from '@/lib/r2';
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
const shouldCreateBucket = /^(1|true|yes|on)$/i.test(
@@ -15,9 +16,26 @@ async function main() {
console.log(`Ensuring object storage bucket exists: ${R2_BUCKET_NAME}`);
await ensureR2BucketExists();
console.log(`Bucket is ready: ${R2_BUCKET_NAME}`);
if (isS3VideoUploadsEnabled()) {
try {
const origins = await ensureR2UploadCors();
console.log(`Configured upload CORS for origins: ${origins.join(', ')}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`Skipping automatic bucket CORS setup (${message}). ` +
'If direct S3 uploads fail in the browser, configure bucket CORS manually.'
);
}
}
}
main().catch((error) => {
logError('Self-host bootstrap failed:', error);
process.exit(1);
});
main()
.catch((error) => {
logError('Self-host bootstrap failed:', error);
process.exit(1);
})
.finally(() => {
r2Client.destroy();
});