From 4bf6e821afb14340dd33d0ce2aa769f8c3f60aa7 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 27 May 2026 17:04:39 +0200 Subject: [PATCH] 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 --- .env.docker.example | 4 + .env.example | 14 +- README.md | 11 +- .../dashboard/dashboard-client.tsx | 12 +- app/(dashboard)/dashboard/page.tsx | 5 +- app/(dashboard)/projects/[projectId]/page.tsx | 8 + .../[projectId]/project-content-client.tsx | 8 +- .../[projectId]/videos/[videoId]/page.tsx | 5 +- .../videos/new/new-video-page-client.tsx | 77 +++-- .../projects/[projectId]/videos/new/page.tsx | 10 +- .../workspaces/[workspaceId]/page.tsx | 4 +- .../[videoId]/versions/[versionId]/route.ts | 14 +- .../videos/[videoId]/versions/route.ts | 99 +++++- .../[projectId]/videos/bunny-init/route.ts | 6 +- .../[projectId]/videos/r2-init/route.ts | 298 ++++++++++++++++++ app/api/projects/[projectId]/videos/route.ts | 144 +++++++-- app/api/upload/audio/route.ts | 6 +- app/api/upload/image/[filename]/route.ts | 28 +- app/api/upload/video/[filename]/route.ts | 112 +++++++ bun.lock | 213 +++---------- components/video-drag-drop-uploader.tsx | 144 +++++++-- components/video-page-content.tsx | 28 +- components/video-page/download-controls.tsx | 8 +- .../video-page/hooks/use-comment-actions.ts | 24 +- .../video-page/hooks/use-download-actions.ts | 16 +- .../video-page/hooks/use-version-actions.ts | 252 +++++++++------ .../video-page/hooks/use-video-player.ts | 131 +++++++- components/video-page/player-core.tsx | 6 +- components/video-page/types.ts | 5 +- .../video-page/version-actions-dialog.tsx | 8 +- components/video-page/video-page-header.tsx | 6 +- lib/client/r2-video-upload.ts | 181 +++++++++++ lib/client/video-thumbnail.ts | 63 ++++ lib/feature-flags.ts | 58 ++++ lib/json-serialize.ts | 8 + lib/r2-cleanup.ts | 34 +- lib/r2-media-proxy.ts | 8 +- lib/r2-upload-session.ts | 45 +++ lib/r2-upload-token.ts | 129 ++++++++ lib/r2-video-finalize.ts | 181 +++++++++++ lib/r2.ts | 269 ++++++++++++++++ lib/storage-quota.ts | 46 ++- lib/validation.ts | 29 ++ lib/video-providers/index.ts | 3 +- lib/video-providers/r2.ts | 42 +++ lib/video-providers/types.ts | 2 +- lib/video-upload-validation.ts | 87 +++++ next.config.ts | 36 +++ package.json | 4 +- .../migration.sql | 1 + .../migration.sql | 41 +++ .../migration.sql | 12 + prisma/schema.prisma | 32 ++ scripts/configure-r2-cors.ts | 20 ++ scripts/docker-db-bootstrap.ts | 11 + scripts/r2-orphan-cleanup.ts | 67 ++-- scripts/self-host-bootstrap.ts | 28 +- 57 files changed, 2707 insertions(+), 436 deletions(-) create mode 100644 app/api/projects/[projectId]/videos/r2-init/route.ts create mode 100644 app/api/upload/video/[filename]/route.ts create mode 100644 lib/client/r2-video-upload.ts create mode 100644 lib/client/video-thumbnail.ts create mode 100644 lib/json-serialize.ts create mode 100644 lib/r2-upload-session.ts create mode 100644 lib/r2-upload-token.ts create mode 100644 lib/r2-video-finalize.ts create mode 100644 lib/video-providers/r2.ts create mode 100644 lib/video-upload-validation.ts create mode 100644 prisma/migrations/20260527120000_add_size_bytes_to_video_versions/migration.sql create mode 100644 prisma/migrations/20260527154000_add_video_upload_sessions/migration.sql create mode 100644 prisma/migrations/20260527155000_add_r2_video_uniqueness_indexes/migration.sql create mode 100644 scripts/configure-r2-cors.ts diff --git a/.env.docker.example b/.env.docker.example index e6b8ebc..8a4331b 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -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" diff --git a/.env.example b/.env.example index d4c9085..5659968 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/README.md b/README.md index 7385967..7965869 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/(dashboard)/dashboard/dashboard-client.tsx b/app/(dashboard)/dashboard/dashboard-client.tsx index 7864152..3d51683 100644 --- a/app/(dashboard)/dashboard/dashboard-client.tsx +++ b/app/(dashboard)/dashboard/dashboard-client.tsx @@ -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 (
- + ); } diff --git a/app/(dashboard)/projects/[projectId]/page.tsx b/app/(dashboard)/projects/[projectId]/page.tsx index d2b5d7e..0421e1e 100644 --- a/app/(dashboard)/projects/[projectId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/page.tsx @@ -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} />
@@ -208,6 +214,8 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP workspaceRole={workspaceRole} totalPages={totalPages} currentPage={page} + directUploadsEnabled={directUploadsEnabled} + directUploadProvider={directUploadProvider} /> ); diff --git a/app/(dashboard)/projects/[projectId]/project-content-client.tsx b/app/(dashboard)/projects/[projectId]/project-content-client.tsx index 1da83e7..a9b33f1 100644 --- a/app/(dashboard)/projects/[projectId]/project-content-client.tsx +++ b/app/(dashboard)/projects/[projectId]/project-content-client.tsx @@ -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({ {/* Project Header */} diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx index 328ca3c..c16ddc6 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx @@ -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'} /> ); } diff --git a/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx b/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx index ed90d34..a029dd7 100644 --- a/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/new/new-video-page-client.tsx @@ -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(null); const pendingBunnyVideoIdRef = useRef(null); const pendingBunnyUploadTokenRef = useRef(null); + const pendingR2ObjectKeyRef = useRef(null); + const pendingR2UploadTokenRef = useRef(null); + const pendingR2ReservationIdRef = useRef(null); const activeTusUploadRef = useRef(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({ Add Video - {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.'} @@ -504,12 +543,12 @@ export default function NewVideoPageClient({ className="mb-6" > Paste URL - {bunnyUploadsEnabled ? ( + {directUploadsEnabled ? ( Direct Upload diff --git a/app/(dashboard)/projects/[projectId]/videos/new/page.tsx b/app/(dashboard)/projects/[projectId]/videos/new/page.tsx index 0f06ede..ddd03a8 100644 --- a/app/(dashboard)/projects/[projectId]/videos/new/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/new/page.tsx @@ -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 ; + return ( + + ); } diff --git a/app/(dashboard)/workspaces/[workspaceId]/page.tsx b/app/(dashboard)/workspaces/[workspaceId]/page.tsx index 318f276..a99982d 100644 --- a/app/(dashboard)/workspaces/[workspaceId]/page.tsx +++ b/app/(dashboard)/workspaces/[workspaceId]/page.tsx @@ -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
0} + canUpload={isAdmin && workspace._count.projects > 0 && isDirectFileUploadEnabled()} + directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'} /> {/* Back & Header */}
diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts index d065f7b..9683386 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts @@ -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); diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts index bac0479..44d60ea 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts @@ -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); diff --git a/app/api/projects/[projectId]/videos/bunny-init/route.ts b/app/api/projects/[projectId]/videos/bunny-init/route.ts index 6b60c12..638d006 100644 --- a/app/api/projects/[projectId]/videos/bunny-init/route.ts +++ b/app/api/projects/[projectId]/videos/bunny-init/route.ts @@ -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)); diff --git a/app/api/projects/[projectId]/videos/r2-init/route.ts b/app/api/projects/[projectId]/videos/r2-init/route.ts new file mode 100644 index 0000000..8944b15 --- /dev/null +++ b/app/api/projects/[projectId]/videos/r2-init/route.ts @@ -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'); + } +} diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index 0047b93..66d9838 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -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); diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index 9d3b942..2e3fcf6 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -37,6 +37,9 @@ const MIME_ALIASES: Record = { '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'); } diff --git a/app/api/upload/image/[filename]/route.ts b/app/api/upload/image/[filename]/route.ts index 771bae6..ebee3bc 100644 --- a/app/api/upload/image/[filename]/route.ts +++ b/app/api/upload/image/[filename]/route.ts @@ -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(); + 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'); } diff --git a/app/api/upload/video/[filename]/route.ts b/app/api/upload/video/[filename]/route.ts new file mode 100644 index 0000000..bacab5b --- /dev/null +++ b/app/api/upload/video/[filename]/route.ts @@ -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 = { + 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(); + 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'); + } +} diff --git a/bun.lock b/bun.lock index 04f3de5..88894c8 100644 --- a/bun.lock +++ b/bun.lock @@ -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/util@5.2.0", "", { "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/client-s3@3.1029.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/client-s3@3.1054.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/core@3.973.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/core@3.974.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/crc64-nvme@3.972.6", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g=="], + "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.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/credential-provider-env@3.972.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/credential-provider-env@3.972.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/credential-provider-http@3.972.27", "", { "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/credential-provider-http@3.972.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/credential-provider-ini@3.972.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/credential-provider-ini@3.972.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/credential-provider-login@3.972.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/credential-provider-login@3.972.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/credential-provider-node@3.972.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/credential-provider-node@3.972.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/credential-provider-process@3.972.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/credential-provider-process@3.972.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/credential-provider-sso@3.972.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/credential-provider-sso@3.972.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/credential-provider-web-identity@3.972.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/credential-provider-web-identity@3.972.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/middleware-bucket-endpoint@3.972.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/middleware-bucket-endpoint@3.972.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/middleware-expect-continue@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-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.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/middleware-flexible-checksums@3.974.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/middleware-flexible-checksums@3.974.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/middleware-sdk-s3@3.972.28", "", { "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/s3-request-presigner@3.1054.0", "", { "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/middleware-ssec@3.972.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/signature-v4-multi-region@3.996.29", "", { "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/middleware-user-agent@3.972.29", "", { "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/token-providers@3.1054.0", "", { "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/nested-clients@3.996.19", "", { "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/region-config-resolver@3.972.11", "", { "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/signature-v4-multi-region@3.996.16", "", { "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/token-providers@3.1026.0", "", { "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/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="], - - "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="], - - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.6", "", { "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/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="], "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.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/util-user-agent-node@3.973.15", "", { "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/xml-builder@3.972.17", "", { "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-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], @@ -380,25 +365,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], + "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.1.6", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], @@ -406,6 +391,8 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -574,105 +561,23 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="], + "@smithy/core": ["@smithy/core@3.24.4", "", { "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/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.4", "", { "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/core@3.23.14", "", { "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/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.13", "", { "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/node-http-handler@4.7.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ=="], - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "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/signature-v4@5.4.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q=="], - "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.13", "", { "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/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], - "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA=="], + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.13", "", { "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/eventstream-serde-universal@4.2.13", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA=="], - - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.16", "", { "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/hash-blob-browser@4.2.14", "", { "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/hash-node@4.2.13", "", { "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/hash-stream-node@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg=="], - - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg=="], - - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], - - "@smithy/md5-js": ["@smithy/md5-js@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ=="], - - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig=="], - - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.29", "", { "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/middleware-retry@4.5.1", "", { "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/middleware-serde@4.2.17", "", { "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/middleware-stack@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw=="], - - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.13", "", { "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/node-http-handler@4.5.2", "", { "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/property-provider@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ=="], - - "@smithy/protocol-http": ["@smithy/protocol-http@5.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg=="], - - "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.13", "", { "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/querystring-parser@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA=="], - - "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0" } }, "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw=="], - - "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.8", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw=="], - - "@smithy/signature-v4": ["@smithy/signature-v4@5.3.13", "", { "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/smithy-client@4.12.9", "", { "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/types@4.14.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ=="], - - "@smithy/url-parser": ["@smithy/url-parser@4.2.13", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw=="], - - "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "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/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], - - "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], - - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], - - "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], - - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.45", "", { "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/util-defaults-mode-node@4.2.49", "", { "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/util-endpoints@3.3.4", "", { "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/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], - - "@smithy/util-middleware": ["@smithy/util-middleware@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow=="], - - "@smithy/util-retry": ["@smithy/util-retry@4.3.1", "", { "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/util-stream@4.5.22", "", { "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/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], - - "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - - "@smithy/util-waiter": ["@smithy/util-waiter@4.2.15", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg=="], - - "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -1126,9 +1031,9 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "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": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -1536,7 +1441,7 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@16.2.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": ["next@16.2.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": ["next-auth@5.0.0-beta.30", "", { "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": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -2010,18 +1917,12 @@ "@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "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/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "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/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], - "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2178,18 +2079,12 @@ "@aws-crypto/sha1-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "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/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "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/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "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": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], @@ -2248,12 +2143,6 @@ "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "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/is-array-buffer@2.2.0", "", { "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/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@commitlint/top-level/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], "@dotenvx/dotenvx/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], diff --git a/components/video-drag-drop-uploader.tsx b/components/video-drag-drop-uploader.tsx index 1dcb40e..e18bfad 100644 --- a/components/video-drag-drop-uploader.tsx +++ b/components/video-drag-drop-uploader.tsx @@ -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(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( diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 8ebd8c2..cdd438c 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -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(null); const videoRef = useRef(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} diff --git a/components/video-page/download-controls.tsx b/components/video-page/download-controls.tsx index 03d5033..64a8997 100644 --- a/components/video-page/download-controls.tsx +++ b/components/video-page/download-controls.tsx @@ -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 ( diff --git a/components/video-page/hooks/use-comment-actions.ts b/components/video-page/hooks/use-comment-actions.ts index 9bd1698..959db55 100644 --- a/components/video-page/hooks/use-comment-actions.ts +++ b/components/video-page/hooks/use-comment-actions.ts @@ -51,6 +51,15 @@ interface UseCommentActionsParams extends CommentActionsConfig { fetchAssets: () => Promise; } +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); diff --git a/components/video-page/hooks/use-download-actions.ts b/components/video-page/hooks/use-download-actions.ts index ae52aaf..fa175d4 100644 --- a/components/video-page/hooks/use-download-actions.ts +++ b/components/video-page/hooks/use-download-actions.ts @@ -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(); diff --git a/components/video-page/hooks/use-version-actions.ts b/components/video-page/hooks/use-version-actions.ts index a43f7f4..cdf6967 100644 --- a/components/video-page/hooks/use-version-actions.ts +++ b/components/video-page/hooks/use-version-actions.ts @@ -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>; @@ -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, diff --git a/components/video-page/hooks/use-video-player.ts b/components/video-page/hooks/use-video-player.ts index 8d2fa46..f18b15f 100644 --- a/components/video-page/hooks/use-video-player.ts +++ b/components/video-page/hooks/use-video-player.ts @@ -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); diff --git a/components/video-page/player-core.tsx b/components/video-page/player-core.tsx index 94dbd08..0e94fb2 100644 --- a/components/video-page/player-core.tsx +++ b/components/video-page/player-core.tsx @@ -170,7 +170,7 @@ export const PlayerCore = memo(function PlayerCore({ onMouseLeave={handleVideoMouseLeave} >
- {activeProviderId === 'bunny' ? ( + {activeProviderId === 'bunny' || activeProviderId === 'r2' ? (

- 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.'}

diff --git a/components/video-page/types.ts b/components/video-page/types.ts index 3f8b08e..e9c229c 100644 --- a/components/video-page/types.ts +++ b/components/video-page/types.ts @@ -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 { diff --git a/components/video-page/version-actions-dialog.tsx b/components/video-page/version-actions-dialog.tsx index 6477981..cff5c42 100644 --- a/components/video-page/version-actions-dialog.tsx +++ b/components/video-page/version-actions-dialog.tsx @@ -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" > Link URL - {bunnyUploadsEnabled ? Upload File : null} + {directUploadsEnabled ? Upload File : null} diff --git a/components/video-page/video-page-header.tsx b/components/video-page/video-page-header.tsx index 46f8c81..564d422 100644 --- a/components/video-page/video-page-header.tsx +++ b/components/video-page/video-page-header.tsx @@ -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({ void; + +function uploadBytesWithProgress( + url: string, + body: Blob | File, + contentType: string, + onProgress?: UploadProgressHandler +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/lib/client/video-thumbnail.ts b/lib/client/video-thumbnail.ts new file mode 100644 index 0000000..178125c --- /dev/null +++ b/lib/client/video-thumbnail.ts @@ -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 { + 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; + }); +} diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 335704e..48fed40 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -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); } diff --git a/lib/json-serialize.ts b/lib/json-serialize.ts new file mode 100644 index 0000000..39cc85a --- /dev/null +++ b/lib/json-serialize.ts @@ -0,0 +1,8 @@ +/** + * Converts values for JSON responses (e.g. Prisma BigInt fields). + */ +export function toJsonSafe(value: T): T { + return JSON.parse( + JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? val.toString() : val)) + ) as T; +} diff --git a/lib/r2-cleanup.ts b/lib/r2-cleanup.ts index a85a566..0acf2be 100644 --- a/lib/r2-cleanup.ts +++ b/lib/r2-cleanup.ts @@ -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 { - 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 }, 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 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 * Collect all media URLs from comments under all videos in a project. */ export async function collectProjectMediaUrls(projectId: string): Promise { - 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 { @@ -135,6 +149,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise { 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 { - 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 { @@ -166,6 +188,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise { 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; } diff --git a/lib/r2-media-proxy.ts b/lib/r2-media-proxy.ts index 5a8cc1e..8b9b3c0 100644 --- a/lib/r2-media-proxy.ts +++ b/lib/r2-media-proxy.ts @@ -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)) { diff --git a/lib/r2-upload-session.ts b/lib/r2-upload-session.ts new file mode 100644 index 0000000..5a6df06 --- /dev/null +++ b/lib/r2-upload-session.ts @@ -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(), + }, + }); +} diff --git a/lib/r2-upload-token.ts b/lib/r2-upload-token.ts new file mode 100644 index 0000000..d59a1b8 --- /dev/null +++ b/lib/r2-upload-token.ts @@ -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; + 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; + } +} diff --git a/lib/r2-video-finalize.ts b/lib/r2-video-finalize.ts new file mode 100644 index 0000000..2b6bc5a --- /dev/null +++ b/lib/r2-video-finalize.ts @@ -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 { + 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 => { + 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}`, + }; +} diff --git a/lib/r2.ts b/lib/r2.ts index 1dd9d4a..9054b64 100644 --- a/lib/r2.ts +++ b/lib/r2.ts @@ -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(); + + 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 { + 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 { + 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 { + 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 { + 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 }; + 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 { + if (!key.startsWith(VIDEO_OBJECT_KEY_PREFIX)) { + throw new Error('Invalid video object key'); + } + + await deleteR2Object(key); +} + +export async function deleteR2Object(key: string): Promise { + assertAllowedObjectKey(key); + + await r2Client.send( + new DeleteObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + }) + ); +} + export { R2_BUCKET_NAME }; diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts index fcfcd53..fdb3df7 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -20,13 +20,22 @@ class QuotaExceededError extends Error {} * every upload. */ export async function getUserTotalStorageBytes(userId: string): Promise { - 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 `, ]); - 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 { +export async function releaseStorageReservation( + reservationId: string | null, + billedUserId?: string | null +): Promise { if (!reservationId) return; - await db.uploadReservation.deleteMany({ where: { id: reservationId } }); + await db.uploadReservation.deleteMany({ + where: { + id: reservationId, + ...(billedUserId ? { billedUserId } : {}), + }, + }); } diff --git a/lib/validation.ts b/lib/validation.ts index 6f71d40..91d5d9e 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -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); +} diff --git a/lib/video-providers/index.ts b/lib/video-providers/index.ts index 30b2d77..9f91069 100644 --- a/lib/video-providers/index.ts +++ b/lib/video-providers/index.ts @@ -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(providers.map((p) => [p.id, p])); diff --git a/lib/video-providers/r2.ts b/lib/video-providers/r2.ts new file mode 100644 index 0000000..eed0be3 --- /dev/null +++ b/lib/video-providers/r2.ts @@ -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 { + const filename = videoId.split('/').pop() || 'Video'; + const nameWithoutExt = filename.replace(/\.[^/.]+$/, ''); + + return { + title: nameWithoutExt, + thumbnailUrl: this.getThumbnailUrl(videoId), + }; + }, +}; diff --git a/lib/video-providers/types.ts b/lib/video-providers/types.ts index c38d3c3..4d36b39 100644 --- a/lib/video-providers/types.ts +++ b/lib/video-providers/types.ts @@ -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 { diff --git a/lib/video-upload-validation.ts b/lib/video-upload-validation.ts new file mode 100644 index 0000000..031fae5 --- /dev/null +++ b/lib/video-upload-validation.ts @@ -0,0 +1,87 @@ +const VIDEO_MIME_TO_EXT: Record = { + '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 = { + 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 }; diff --git a/next.config.ts b/next.config.ts index 9decc66..4f5cf82 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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(); + + 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); diff --git a/package.json b/package.json index 38b508d..a36a140 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260527120000_add_size_bytes_to_video_versions/migration.sql b/prisma/migrations/20260527120000_add_size_bytes_to_video_versions/migration.sql new file mode 100644 index 0000000..e1fd12a --- /dev/null +++ b/prisma/migrations/20260527120000_add_size_bytes_to_video_versions/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "video_versions" ADD COLUMN "size_bytes" BIGINT NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260527154000_add_video_upload_sessions/migration.sql b/prisma/migrations/20260527154000_add_video_upload_sessions/migration.sql new file mode 100644 index 0000000..bfaaddf --- /dev/null +++ b/prisma/migrations/20260527154000_add_video_upload_sessions/migration.sql @@ -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"); diff --git a/prisma/migrations/20260527155000_add_r2_video_uniqueness_indexes/migration.sql b/prisma/migrations/20260527155000_add_r2_video_uniqueness_indexes/migration.sql new file mode 100644 index 0000000..6b6f854 --- /dev/null +++ b/prisma/migrations/20260527155000_add_r2_video_uniqueness_indexes/migration.sql @@ -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/%'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1764939..9c1114f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/scripts/configure-r2-cors.ts b/scripts/configure-r2-cors.ts new file mode 100644 index 0000000..dd53a88 --- /dev/null +++ b/scripts/configure-r2-cors.ts @@ -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(); + }); diff --git a/scripts/docker-db-bootstrap.ts b/scripts/docker-db-bootstrap.ts index 47c2d06..65846a5 100644 --- a/scripts/docker-db-bootstrap.ts +++ b/scripts/docker-db-bootstrap.ts @@ -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; } diff --git a/scripts/r2-orphan-cleanup.ts b/scripts/r2-orphan-cleanup.ts index bf3c3ad..7b2adaf 100644 --- a/scripts/r2-orphan-cleanup.ts +++ b/scripts/r2-orphan-cleanup.ts @@ -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> { ).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> { 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; diff --git a/scripts/self-host-bootstrap.ts b/scripts/self-host-bootstrap.ts index acd5a18..ffc5954 100644 --- a/scripts/self-host-bootstrap.ts +++ b/scripts/self-host-bootstrap.ts @@ -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(); + });